Compare commits

..

No commits in common. "main" and "v0.3.dev1" have entirely different histories.

225 changed files with 3568 additions and 27576 deletions

459
.circleci/config.yml Normal file
View file

@ -0,0 +1,459 @@
version: 2.1
aliases:
- &install-dependencies-deb
name: Install dependencies (deb)
command: |
export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true
apt-get update
apt-get install -y git ssh podman python-all dh-python python3 python3-stdeb python3-pyside2.qtcore python3-pyside2.qtgui python3-pyside2.qtwidgets python3-appdirs python3-click python3-xdg python3-requests python3-colorama
- &install-dependencies-rpm
name: Install dependencies (rpm)
command: |
dnf install -y podman git openssh make automake gcc gcc-c++ rpm-build python3-setuptools python3-pyside2 python3-appdirs python3-click python3-pyxdg python3-requests python3-colorama
- &build-deb
name: Build the .deb package
command: |
./install/linux/build-deb.py
dpkg -i deb_dist/dangerzone_*-1_all.deb
ls -lh deb_dist/dangerzone_*-1_all.deb
- &build-rpm
name: Build the .rpm package
command: |
./install/linux/build-rpm.py
dnf install -y dist/dangerzone-*-1.noarch.rpm
ls -lh dist/dangerzone-*-1.noarch.rpm
- &restore-cache
key: v1-{{ checksum "container/Dockerfile" }}-{{ checksum "container/dangerzone.py" }}
paths:
- /caches/container.tar.gz
- /caches/image-id.txt
- &copy-image
name: Copy container image into package
command: |
cp /caches/container.tar.gz share/
cp /caches/image-id.txt share/
- &deploy-packagecloud
name: Deploy to packagecloud.io
command: |
VERSION=$(cat share/version.txt)
if [[ "${PACKAGE_TYPE}" == "deb" ]]; then
# install packagecloud.io
apt-get install -y ruby-dev rubygems
gem install -N rake
gem install -N package_cloud
# deploy the package
package_cloud push firstlookmedia/code/${PACKAGECLOUD_DISTO} deb_dist/dangerzone_${VERSION}-1_all.deb
package_cloud push firstlookmedia/code/${PACKAGECLOUD_DISTO} deb_dist/dangerzone_${VERSION}-1.dsc
elif [[ "${PACKAGE_TYPE}" == "rpm" ]]; then
# install packagecloud.
dnf install -y ruby-devel
gem install package_cloud
# deploy the package
package_cloud push firstlookmedia/code/${PACKAGECLOUD_DISTO} dist/dangerzone-${VERSION}-1.noarch.rpm
package_cloud push firstlookmedia/code/${PACKAGECLOUD_DISTO} dist/dangerzone-${VERSION}-1.src.rpm
fi
jobs:
build-container-image:
working_directory: /app
docker:
- image: docker:dind
steps:
- checkout
- restore_cache:
keys:
- v1-{{ checksum "container/Dockerfile" }}-{{ checksum "container/dangerzone.py" }}
- setup_remote_docker
- run:
name: Build Dangerzone image
command: |
if [ -f "/caches/container.tar.gz" ]; then
echo "Already cached, skipping"
else
docker build --cache-from=dangerzone.rocks/dangerzone --tag dangerzone.rocks/dangerzone container
fi
- run:
name: Save Dangerzone image and image-id.txt to cache
command: |
if [ -f "/caches/container.tar.gz" ]; then
echo "Already cached, skipping"
else
mkdir -p /caches
docker save -o /caches/container.tar dangerzone.rocks/dangerzone
gzip -f /caches/container.tar
docker image ls dangerzone.rocks/dangerzone | grep "dangerzone.rocks/dangerzone" | tr -s ' ' | cut -d' ' -f3 > /caches/image-id.txt
fi
- save_cache:
key: v1-{{ checksum "container/Dockerfile" }}-{{ checksum "container/dangerzone.py" }}
paths:
- /caches/container.tar.gz
- /caches/image-id.txt
convert-test-docs:
machine:
image: ubuntu-2004:202111-01
steps:
# https://www.atlantic.net/dedicated-server-hosting/how-to-install-and-use-podman-on-ubuntu-20-04/
- run:
name: Install podman on Ubuntu 20.04
command: |
export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true
source /etc/os-release
sudo sh -c "echo 'deb http://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list"
wget -nv https://download.opensuse.org/repositories/devel:kubic:libcontainers:stable/xUbuntu_${VERSION_ID}/Release.key -O- | sudo apt-key add -
sudo apt-get update -qq -y
sudo apt-get -qq --yes install podman
podman --version
- checkout
- run:
name: Install poetry dependencies
command: |
sudo pip3 install poetry
poetry install
- run:
name: Prepare cache directory
command: |
sudo mkdir -p /caches
sudo chown -R $USER:$USER /caches
- restore_cache: *restore-cache
- run: *copy-image
- run:
name: Convert each test document
command: |
for FILE in $(ls test_docs); do
echo Converting $FILE
poetry run ./dev_scripts/dangerzone-cli test_docs/$FILE
echo
done
build-ubuntu-impish:
docker:
- image: ubuntu:21.10
resource_class: medium+
steps:
- run: *install-dependencies-deb
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-deb
deploy-ubuntu-impish:
docker:
- image: ubuntu:21.10
resource_class: medium+
steps:
- run: *install-dependencies-deb
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-deb
- run:
environment:
PACKAGE_TYPE: "deb"
PACKAGECLOUD_DISTRO: "ubuntu/impish"
<<: *deploy-packagecloud
build-ubuntu-hirsute:
docker:
- image: ubuntu:21.04
resource_class: medium+
steps:
- run: *install-dependencies-deb
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-deb
deploy-ubuntu-hirsute:
docker:
- image: ubuntu:21.04
resource_class: medium+
steps:
- run: *install-dependencies-deb
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-deb
- run:
environment:
PACKAGE_TYPE: "deb"
PACKAGECLOUD_DISTRO: "ubuntu/hirsute"
<<: *deploy-packagecloud
build-ubuntu-groovy:
docker:
- image: ubuntu:20.10
resource_class: medium+
steps:
- run: *install-dependencies-deb
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-deb
deploy-ubuntu-groovy:
docker:
- image: ubuntu:20.10
resource_class: medium+
steps:
- run: *install-dependencies-deb
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-deb
- run:
environment:
PACKAGE_TYPE: "deb"
PACKAGECLOUD_DISTRO: "ubuntu/groovy"
<<: *deploy-packagecloud
build-debian-bookworm:
docker:
- image: debian:bookworm
resource_class: medium+
steps:
- run: *install-dependencies-deb
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-deb
deploy-debian-bookworm:
docker:
- image: debian:bookworm
resource_class: medium+
steps:
- run: *install-dependencies-deb
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-deb
- run:
environment:
PACKAGE_TYPE: "deb"
PACKAGECLOUD_DISTRO: "debian/bookworm"
<<: *deploy-packagecloud
build-debian-bullseye:
docker:
- image: debian:bullseye
resource_class: medium+
steps:
- run: *install-dependencies-deb
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-deb
deploy-debian-bullseye:
docker:
- image: debian:bullseye
resource_class: medium+
steps:
- run: *install-dependencies-deb
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-deb
- run:
environment:
PACKAGE_TYPE: "deb"
PACKAGECLOUD_DISTRO: "debian/bullseye"
<<: *deploy-packagecloud
build-fedora-35:
docker:
- image: fedora:35
resource_class: medium+
steps:
- run: *install-dependencies-rpm
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-rpm
deploy-fedora-35:
docker:
- image: fedora:35
resource_class: medium+
steps:
- run: *install-dependencies-rpm
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-rpm
- run:
environment:
PACKAGE_TYPE: "rpm"
PACKAGECLOUD_DISTRO: "fedora/35"
<<: *deploy-packagecloud
build-fedora-34:
docker:
- image: fedora:34
resource_class: medium+
steps:
- run: *install-dependencies-rpm
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-rpm
deploy-fedora-34:
docker:
- image: fedora:34
resource_class: medium+
steps:
- run: *install-dependencies-rpm
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-rpm
- run:
environment:
PACKAGE_TYPE: "rpm"
PACKAGECLOUD_DISTRO: "fedora/34"
<<: *deploy-packagecloud
build-fedora-33:
docker:
- image: fedora:33
resource_class: medium+
steps:
- run: *install-dependencies-rpm
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-rpm
deploy-fedora-33:
docker:
- image: fedora:33
resource_class: medium+
steps:
- run: *install-dependencies-rpm
- checkout
- restore_cache: *restore-cache
- run: *copy-image
- run: *build-rpm
- run:
environment:
PACKAGE_TYPE: "rpm"
PACKAGECLOUD_DISTRO: "fedora/33"
<<: *deploy-packagecloud
workflows:
version: 2
build:
jobs:
- build-container-image
- convert-test-docs:
requires:
- build-container-image
- build-ubuntu-impish:
requires:
- build-container-image
- build-ubuntu-hirsute:
requires:
- build-container-image
- build-ubuntu-groovy:
requires:
- build-container-image
- build-debian-bullseye:
requires:
- build-container-image
- build-debian-bookworm:
requires:
- build-container-image
- build-fedora-35:
requires:
- build-container-image
- build-fedora-34:
requires:
- build-container-image
- build-fedora-33:
requires:
- build-container-image
build-and-deploy:
jobs:
- build-container-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- deploy-ubuntu-impish:
requires:
- build-container-image
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- deploy-ubuntu-hirsute:
requires:
- build-container-image
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- deploy-ubuntu-groovy:
requires:
- build-container-image
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- deploy-debian-bookworm:
requires:
- build-container-image
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- deploy-debian-bullseye:
requires:
- build-container-image
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- deploy-fedora-35:
requires:
- build-container-image
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- deploy-fedora-34:
requires:
- build-container-image
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- deploy-fedora-33:
requires:
- build-container-image
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/

5
.gitattributes vendored
View file

@ -1,5 +0,0 @@
* text=auto
*.py text eol=lf
*.jpg -text
*.gif -text
*.png -text

View file

@ -1,67 +0,0 @@
name: Bug Report (Linux)
description: File a bug report for Linux.
labels: ["bug", "triage"]
projects: ["freedomofpress/dangerzone"]
body:
- type: markdown
attributes:
value: |
Hi, and thanks for taking the time to open this bug report.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: What was the expected behaviour, and what was the actual behaviour? Can you specify the steps you followed, so that we can reproduce?
placeholder: "A bug happened!"
validations:
required: true
- type: textarea
id: os-version
attributes:
label: Linux distribution
description: |
What is the name and version of your Linux distribution? You can find it out with `cat /etc/os-release`
placeholder: Ubuntu 22.04.5 LTS
validations:
required: true
- type: textarea
id: dangerzone-version
attributes:
label: Dangerzone version
description: Which version of Dangerzone are you using?
validations:
required: true
- type: textarea
id: podman-info
attributes:
label: Podman info
description: |
Please copy and paste the following commands in your terminal, and provide us with the output:
```shell
podman version
podman info -f 'json'
podman images
podman run hello-world
```
This will be automatically formatted into code, so no need for backticks.
render: shell
- type: textarea
id: logs
attributes:
label: Document conversion logs
description: |
If the bug occurs during document conversion, we'd like some logs from this process. Please copy and paste the following commands in your terminal, and provide us with the output (replace `/path/to/file` with the path to your document):
```bash
dangerzone-cli /path/to/file
```
render: shell
- type: textarea
id: additional-info
attributes:
label: Additional info
description: |
Please provide us with any additional info, such as logs, extra content, that may help us debug this issue.

View file

@ -1,82 +0,0 @@
name: Bug Report (MacOS)
description: File a bug report for MacOS.
labels: ["bug", "triage"]
projects: ["freedomofpress/dangerzone"]
body:
- type: markdown
attributes:
value: |
Hi, and thanks for taking the time to open this bug report.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: What was the expected behaviour, and what was the actual behaviour? Can you specify the steps you followed, so that we can reproduce?
placeholder: "A bug happened!"
validations:
required: true
- type: textarea
id: os-version
attributes:
label: operating system version
description: Which version of MacOS do you use? You can follow [this link](https://support.apple.com/en-us/109033) to find out more.
placeholder: macOS Sequoia 15
validations:
required: true
- type: dropdown
id: proc-architecture
attributes:
label: Processor type
description: |
Which kind of processor do you use?
You can follow [this link](https://support.apple.com/en-us/109033) to find out more.
options:
- Intel
- Apple Silicon
validations:
required: true
- type: textarea
id: dangerzone-version
attributes:
label: Dangerzone version
description: Which version of Dangerzone are you using?
validations:
required: true
- type: textarea
id: docker-info
attributes:
label: Docker info
description: |
Please copy and paste the following commands in your
terminal, and provide us with the output:
```shell
docker version
docker info -f 'json'
docker images
docker run hello-world
```
This will be automatically formatted into code, so no need for backticks.
render: shell
- type: textarea
id: logs
attributes:
label: Document conversion logs
description: |
If the bug occurs during document conversion, we'd like some logs from this process. Please copy and paste the following commands in your terminal, and provide us with the output (replace `/path/to/file` with the path to your document):
```bash
/Applications/Dangerzone.app/Contents/MacOS/dangerzone-cli /path/to/file
```
render: shell
- type: textarea
id: additional-info
attributes:
label: Additional info
description: |
Please provide us with any additional info, such as logs, extra content, that may help us debug this issue.

View file

@ -1,67 +0,0 @@
name: Bug Report (Windows)
description: File a bug report for Windows.
labels: ["bug", "triage"]
projects: ["freedomofpress/dangerzone"]
body:
- type: markdown
attributes:
value: |
Hi, and thanks for taking the time to open this bug report.
- type: textarea
id: what-happened
attributes:
label: What happened?
description: What was the expected behaviour, and what was the actual behaviour? Can you specify the steps you followed, so that we can reproduce?
placeholder: "A bug happened!"
validations:
required: true
- type: textarea
id: os-version
attributes:
label: operating system version
description: |
Which version of Windows do you use? Follow [this link](https://learn.microsoft.com/en-us/windows/client-management/client-tools/windows-version-search) to find out.
validations:
required: true
- type: textarea
id: dangerzone-version
attributes:
label: Dangerzone version
description: Which version of Dangerzone are you using?
validations:
required: true
- type: textarea
id: docker-info
attributes:
label: Docker info
description: |
Please copy and paste the following commands in your
terminal, and provide us with the output:
```shell
docker version
docker info -f 'json'
docker images
docker run hello-world
```
This will be automatically formatted into code, so no need for backticks.
render: shell
- type: textarea
id: logs
attributes:
label: Document conversion logs
description: |
If the bug occurs during document conversion, we'd like some logs from this process. Please copy and paste the following commands in your terminal, and provide us with the output (replace `\path\to\file` with the path to your document):
```bash
'C:\Program Files (x86)\Dangerzone\dangerzone-cli.exe' \path\to\file
```
render: shell
- type: textarea
id: additional-info
attributes:
label: Additional info
description: |
Please provide us with any additional info, such as logs, extra content, that may help us debug this issue.

View file

@ -1 +0,0 @@
blank_issues_enabled: true

View file

@ -1,21 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**What is the feature you think should be a good addition to Dangerzone?**
?
**Is your feature request related to a problem? Please describe.**
It's always useful for us to know more about your context, and why you think
this would be a great addition. Don't hesitate to put some details about your
current workflow and how this could be useful to you.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View file

@ -1,248 +0,0 @@
name: Build and push multi-arch container image
on:
workflow_call:
inputs:
registry:
required: true
type: string
registry_user:
required: true
type: string
image_name:
required: true
type: string
reproduce:
required: true
type: boolean
secrets:
registry_token:
required: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dev. dependencies
run: |-
sudo apt-get update
sudo apt-get install -y git python3-poetry --no-install-recommends
poetry install --only package
- name: Verify that the Dockerfile matches the commited template and params
run: |-
cp Dockerfile Dockerfile.orig
make Dockerfile
diff Dockerfile.orig Dockerfile
prepare:
runs-on: ubuntu-latest
outputs:
debian_archive_date: ${{ steps.params.outputs.debian_archive_date }}
source_date_epoch: ${{ steps.params.outputs.source_date_epoch }}
image: ${{ steps.params.outputs.full_image_name }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute image parameters
id: params
run: |
source Dockerfile.env
DEBIAN_ARCHIVE_DATE=$(date -u +'%Y%m%d')
SOURCE_DATE_EPOCH=$(date -u -d ${DEBIAN_ARCHIVE_DATE} +"%s")
TAG=${DEBIAN_ARCHIVE_DATE}-$(git describe --long --first-parent | tail -c +2)
FULL_IMAGE_NAME=${{ inputs.registry }}/${{ inputs.image_name }}:${TAG}
echo "debian_archive_date=${DEBIAN_ARCHIVE_DATE}" >> $GITHUB_OUTPUT
echo "source_date_epoch=${SOURCE_DATE_EPOCH}" >> $GITHUB_OUTPUT
echo "tag=${DEBIAN_ARCHIVE_DATE}-${TAG}" >> $GITHUB_OUTPUT
echo "full_image_name=${FULL_IMAGE_NAME}" >> $GITHUB_OUTPUT
echo "buildkit_image=${BUILDKIT_IMAGE}" >> $GITHUB_OUTPUT
build:
name: Build ${{ matrix.platform.name }} image
runs-on: ${{ matrix.platform.runs-on }}
needs:
- prepare
outputs:
debian_archive_date: ${{ needs.prepare.outputs.debian_archive_date }}
source_date_epoch: ${{ needs.prepare.outputs.source_date_epoch }}
image: ${{ needs.prepare.outputs.image }}
strategy:
fail-fast: false
matrix:
platform:
- runs-on: "ubuntu-24.04"
name: "linux/amd64"
- runs-on: "ubuntu-24.04-arm"
name: "linux/arm64"
steps:
- uses: actions/checkout@v4
- name: Prepare
run: |
platform=${{ matrix.platform.name }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ inputs.registry_user }}
password: ${{ secrets.registry_token }}
# Instructions for reproducibly building a container image are taken from:
# https://github.com/freedomofpress/repro-build?tab=readme-ov-file#build-and-push-a-container-image-on-github-actions
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: image=${{ needs.prepare.outputs.buildkit_image }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: ./dangerzone/
file: Dockerfile
build-args: |
DEBIAN_ARCHIVE_DATE=${{ needs.prepare.outputs.debian_archive_date }}
SOURCE_DATE_EPOCH=${{ needs.prepare.outputs.source_date_epoch }}
provenance: false
outputs: type=image,"name=${{ inputs.registry }}/${{ inputs.image_name }}",push-by-digest=true,push=true,rewrite-timestamp=true,name-canonical=true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
echo "Image digest is: ${digest}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
outputs:
debian_archive_date: ${{ needs.build.outputs.debian_archive_date }}
source_date_epoch: ${{ needs.build.outputs.source_date_epoch }}
image: ${{ needs.build.outputs.image }}
digest_root: ${{ steps.image.outputs.digest_root }}
digest_amd64: ${{ steps.image.outputs.digest_amd64 }}
digest_arm64: ${{ steps.image.outputs.digest_arm64 }}
steps:
- uses: actions/checkout@v4
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ inputs.registry_user }}
password: ${{ secrets.registry_token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: image=${{ env.BUILDKIT_IMAGE }}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
DIGESTS=$(printf '${{ needs.build.outputs.image }}@sha256:%s ' *)
docker buildx imagetools create -t ${{ needs.build.outputs.image }} ${DIGESTS}
- name: Inspect image
id: image
run: |
# Inspect the image
docker buildx imagetools inspect ${{ needs.build.outputs.image }}
docker buildx imagetools inspect ${{ needs.build.outputs.image }} --format "{{json .Manifest}}" > manifest
# Calculate and print the digests
digest_root=$(jq -r .digest manifest)
digest_amd64=$(jq -r '.manifests[] | select(.platform.architecture=="amd64") | .digest' manifest)
digest_arm64=$(jq -r '.manifests[] | select(.platform.architecture=="arm64") | .digest' manifest)
echo "The image digests are:"
echo " Root: $digest_root"
echo " linux/amd64: $digest_amd64"
echo " linux/arm64: $digest_arm64"
# NOTE: Set the digests as an output because the `env` context is not
# available to the inputs of a reusable workflow call.
echo "digest_root=$digest_root" >> "$GITHUB_OUTPUT"
echo "digest_amd64=$digest_amd64" >> "$GITHUB_OUTPUT"
echo "digest_arm64=$digest_arm64" >> "$GITHUB_OUTPUT"
# This step calls the container workflow to generate provenance and push it to
# the container registry.
provenance:
needs:
- merge
strategy:
matrix:
manifest_type:
- root
- amd64
- arm64
permissions:
actions: read # for detecting the Github Actions environment.
id-token: write # for creating OIDC tokens for signing.
packages: write # for uploading attestations.
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
with:
digest: ${{ needs.merge.outputs[format('digest_{0}', matrix.manifest_type)] }}
image: ${{ needs.merge.outputs.image }}
registry-username: ${{ inputs.registry_user }}
secrets:
registry-password: ${{ secrets.registry_token }}
# This step ensures that the image is reproducible
check-reproducibility:
if: ${{ inputs.reproduce }}
needs:
- merge
runs-on: ${{ matrix.platform.runs-on }}
strategy:
fail-fast: false
matrix:
platform:
- runs-on: "ubuntu-24.04"
name: "amd64"
- runs-on: "ubuntu-24.04-arm"
name: "arm64"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Reproduce the same container image
run: |
./dev_scripts/reproduce-image.py \
--runtime \
docker \
--debian-archive-date \
${{ needs.merge.outputs.debian_archive_date }} \
--platform \
linux/${{ matrix.platform.name }} \
${{ needs.merge.outputs[format('digest_{0}', matrix.platform.name)] }}

View file

@ -1,98 +0,0 @@
name: Build dev environments
on:
pull_request:
push:
branches:
- main
- "test/**"
schedule:
- cron: "0 0 * * *" # Run every day at 00:00 UTC.
permissions:
packages: write
env:
IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }}
REGISTRY_USER: ${{ github.actor }}
REGISTRY_PASSWORD: ${{ github.token }}
# Each day, build and publish to ghcr.io:
#
# - the dangerzone/dangerzone container image
# - the dangerzone/build/{debian,ubuntu,fedora}:version
# dev environments used to run the tests
#
# End-user environments are not published to the GHCR because
# they need .rpm or .deb files to be built, which is what we
# want to test.
jobs:
build-dev-environment:
name: "Build dev-env (${{ matrix.distro }}-${{ matrix.version }})"
runs-on: ubuntu-latest
strategy:
matrix:
include:
- distro: ubuntu
version: "22.04"
- distro: ubuntu
version: "24.04"
- distro: ubuntu
version: "24.10"
- distro: ubuntu
version: "25.04"
- distro: debian
version: bullseye
- distro: debian
version: bookworm
- distro: debian
version: trixie
- distro: fedora
version: "40"
- distro: fedora
version: "41"
- distro: fedora
version: "42"
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Login to GHCR
run: |
echo ${{ github.token }} | podman login ghcr.io -u USERNAME --password-stdin
- name: Build dev environment
run: |
./dev_scripts/env.py --distro ${{ matrix.distro }} \
--version ${{ matrix.version }} \
build-dev --sync
build-container-image:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Cache container image
id: cache-container-image
uses: actions/cache@v4
with:
key: v5-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/*.py', 'dangerzone/container_helpers/*', 'install/common/build-image.py') }}
path: |
share/container.tar
share/image-id.txt
- name: Build Dangerzone image
if: ${{ steps.cache-container-image.outputs.cache-hit != 'true' }}
run: |
python3 ./install/common/build-image.py

View file

@ -1,30 +0,0 @@
name: Check branch conformity
on:
pull_request:
types: ["opened", "labeled", "unlabeled", "reopened", "synchronize"]
jobs:
prevent-fixup-commits:
runs-on: ubuntu-latest
env:
target: debian-bookworm
distro: debian
version: bookworm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: prevent fixup commits
run: |
git fetch origin
git status
git log --pretty=format:%s origin/main..HEAD | grep -ie '^fixup\|^wip' && exit 1 || true
check-changelog:
runs-on: ubuntu-latest
name: Ensure CHANGELOG.md is populated for user-visible changes
steps:
# Pin the GitHub action to a specific commit that we have audited and know
# how it works.
- uses: tarides/changelog-check-action@509965da3b8ac786a5e2da30c2ccf9661189121f
with:
changelog: CHANGELOG.md

View file

@ -1,109 +0,0 @@
# Test official instructions for installing Dangerzone
# ====================================================
#
# The installation instructions have been copied from our INSTALL.md file.
# NOTE: When you change either place, please make sure to keep the two files in
# sync.
# NOTE: Because the commands run as root, the use of sudo is not necessary.
name: Test official instructions for installing Dangerzone
on:
schedule:
- cron: '0 0 * * *' # Run every day at 00:00 UTC.
workflow_dispatch:
jobs:
install-from-apt-repo:
name: "Install Dangerzone on ${{ matrix.distro}} ${{ matrix.version }}"
runs-on: ubuntu-latest
container: ${{ matrix.distro }}:${{ matrix.version }}
strategy:
matrix:
include:
- distro: ubuntu
version: "25.04" # plucky
- distro: ubuntu
version: "24.10" # oracular
- distro: ubuntu
version: "24.04" # noble
- distro: ubuntu
version: "22.04" # jammy
- distro: debian
version: "trixie" # 13
- distro: debian
version: "12" # bookworm
- distro: debian
version: "11" # bullseye
steps:
- name: Add packages.freedom.press PGP key (gpg --keyring)
if: matrix.version != 'trixie' && matrix.version != "25.04"
run: |
apt-get update && apt-get install -y gnupg2 ca-certificates
dirmngr # NOTE: This is a command that's necessary only in containers
# The key needs to be in the GPG keybox database format so the
# signing subkey is detected by apt-secure.
gpg --keyserver hkps://keys.openpgp.org \
--no-default-keyring --keyring ./fpf-apt-tools-archive-keyring.gpg \
--recv-keys "DE28 AB24 1FA4 8260 FAC9 B8BA A7C9 B385 2260 4281"
mkdir -p /etc/apt/keyrings/
mv ./fpf-apt-tools-archive-keyring.gpg /etc/apt/keyrings/.
- name: Add packages.freedom.press PGP key (sq)
if: matrix.version == 'trixie' || matrix.version == '25.04'
run: |
apt-get update && apt-get install -y ca-certificates sq
mkdir -p /etc/apt/keyrings/
# On debian trixie, apt-secure uses `sqv` to verify the signatures
# so we need to retrieve PGP keys and store them using the base64 format.
sq network keyserver \
--server hkps://keys.openpgp.org \
search "DE28 AB24 1FA4 8260 FAC9 B8BA A7C9 B385 2260 4281" \
--output - \
| sq packet dearmor \
> /etc/apt/keyrings/fpf-apt-tools-archive-keyring.gpg
- name: Add packages.freedom.press to our APT sources
run: |
. /etc/os-release
echo "deb [signed-by=/etc/apt/keyrings/fpf-apt-tools-archive-keyring.gpg] \
https://packages.freedom.press/apt-tools-prod ${VERSION_CODENAME?} main" \
| tee /etc/apt/sources.list.d/fpf-apt-tools.list
- name: Install Dangerzone
run: |
apt update
apt install -y dangerzone
install-from-yum-repo:
name: "Install Dangerzone on ${{ matrix.distro}} ${{ matrix.version }}"
runs-on: ubuntu-latest
container: ${{ matrix.distro }}:${{ matrix.version }}
strategy:
matrix:
include:
- distro: fedora
version: 40
- distro: fedora
version: 41
- distro: fedora
version: 42
steps:
- name: Add packages.freedom.press to our YUM sources
run: |
dnf install -y 'dnf-command(config-manager)'
dnf-3 config-manager --add-repo=https://packages.freedom.press/yum-tools-prod/dangerzone/dangerzone.repo
- name: Replace 'rawhide' string with Fedora version
# The previous command has created a `dangerzone.repo` file. The
# config-manager plugin should have substituted the $releasever variable
# with the Fedora version number. However, for unreleased Fedora
# versions, this gets translated to "rawhide", even though they do have
# a number. To fix this, we need to substitute the "rawhide" string
# witht the proper Fedora version.
run: |
source /etc/os-release
sed -i "s/rawhide/${VERSION_ID}/g" /etc/yum.repos.d/dangerzone.repo
- name: Install Dangerzone
# FIXME: We add the `-y` flag here, in lieu of a better way to check the
# Dangerzone signature.
run: dnf install -y dangerzone

View file

@ -1,483 +0,0 @@
name: Tests
on:
pull_request:
push:
branches:
- main
- "test/**"
schedule:
- cron: "2 0 * * *" # Run every day at 02:00 UTC.
workflow_dispatch:
permissions:
packages: write
env:
REGISTRY_USER: ${{ github.actor }}
REGISTRY_PASSWORD: ${{ github.token }}
IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }}
QT_SELECT: "qt6"
# Disable multiple concurrent runs on the same branch
# When a new CI build is triggered, it will cancel the
# other in-progress ones (for the same branch)
concurrency:
group: ${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
run-lint:
runs-on: ubuntu-latest
container:
image: debian:bookworm
steps:
- uses: actions/checkout@v4
- name: Install dev. dependencies
run: |-
apt-get update
apt-get install -y git make python3 python3-poetry --no-install-recommends
poetry install --only lint,test
- name: Run linters to enforce code style
run: poetry run make lint
- name: Check that the QA script is up to date with the docs
run: "./dev_scripts/qa.py --check-refs"
# This is already built daily by the "build.yml" file
# But we also want to include this in the checks that run on each push.
build-container-image:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Cache container image
id: cache-container-image
uses: actions/cache@v4
with:
key: v5-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/*.py', 'dangerzone/container_helpers/*', 'install/common/build-image.py') }}
path: |-
share/container.tar
share/image-id.txt
- name: Build Dangerzone container image
if: ${{ steps.cache-container-image.outputs.cache-hit != 'true' }}
run: |
python3 ./install/common/build-image.py
- name: Upload container image
uses: actions/upload-artifact@v4
with:
name: container.tar
path: share/container.tar
download-tessdata:
name: Download and cache Tesseract data
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache Tessdata
id: cache-tessdata
uses: actions/cache@v4
with:
path: share/tessdata/
key: v1-tessdata-${{ hashFiles('./install/common/download-tessdata.py') }}
enableCrossOsArchive: true
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Download Tessdata
run: |-
if [ -f "share/tessdata" ]; then
echo "Already cached, skipping"
else
python3 ./install/common/download-tessdata.py
fi
windows:
runs-on: windows-latest
needs:
- download-tessdata
env:
DUMMY_CONVERSION: 1
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install poetry
- run: poetry install
- name: Restore cached tessdata
uses: actions/cache/restore@v4
with:
path: share/tessdata/
enableCrossOsArchive: true
fail-on-cache-miss: true
key: v1-tessdata-${{ hashFiles('./install/common/download-tessdata.py') }}
- name: Run CLI tests
run: poetry run make test
- name: Set up .NET CLI environment
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.x"
- name: Install WiX Toolset
run: dotnet tool install --global wix --version 5.0.2
- name: Add WiX UI extension
run: wix extension add --global WixToolset.UI.wixext/5.0.2
- name: Build the MSI installer
# NOTE: This also builds the .exe internally.
run: poetry run .\install\windows\build-app.bat
- name: Upload MSI installer
uses: actions/upload-artifact@v4
with:
name: Dangerzone.msi
path: "dist/Dangerzone.msi"
if-no-files-found: error
compression-level: 0
macOS:
name: "macOS (${{ matrix.arch }})"
runs-on: ${{ matrix.runner }}
needs:
- download-tessdata
strategy:
matrix:
include:
- runner: macos-latest # CPU type: Apple Silicon (M1)
arch: arch64
- runner: macos-13 # CPU type: Intel x86_64
arch: x86_64
env:
DUMMY_CONVERSION: 1
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Restore cached tessdata
uses: actions/cache/restore@v4
with:
path: share/tessdata/
enableCrossOsArchive: true
fail-on-cache-miss: true
key: v1-tessdata-${{ hashFiles('./install/common/download-tessdata.py') }}
- run: pip install poetry
- run: poetry install
- name: Run CLI tests
run: poetry run make test
- name: Build macOS app
run: poetry run python ./install/macos/build-app.py
- name: Upload macOS app
uses: actions/upload-artifact@v4
with:
name: Dangerzone-${{ matrix.arch }}.app
path: "dist/Dangerzone.app"
if-no-files-found: error
compression-level: 0
build-deb:
needs:
- build-container-image
name: "build-deb (${{ matrix.distro }} ${{ matrix.version }})"
runs-on: ubuntu-latest
strategy:
matrix:
include:
- distro: ubuntu
version: "22.04"
- distro: ubuntu
version: "24.04"
- distro: ubuntu
version: "24.10"
- distro: ubuntu
version: "25.04"
- distro: debian
version: bullseye
- distro: debian
version: bookworm
- distro: debian
version: trixie
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Login to GHCR
run: |
echo ${{ github.token }} | podman login ghcr.io -u USERNAME --password-stdin
- name: Get the dev environment
run: |
./dev_scripts/env.py \
--distro ${{ matrix.distro }} \
--version ${{ matrix.version }} \
build-dev --sync
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Restore container cache
uses: actions/cache/restore@v4
with:
key: v5-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/*.py', 'dangerzone/container_helpers/*', 'install/common/build-image.py') }}
path: |-
share/container.tar
share/image-id.txt
fail-on-cache-miss: true
- name: Build Dangerzone .deb
run: |
./dev_scripts/env.py --distro ${{ matrix.distro }} \
--version ${{ matrix.version }} \
run --dev --no-gui ./dangerzone/install/linux/build-deb.py
- name: Upload Dangerzone .deb
if: matrix.distro == 'debian' && matrix.version == 'bookworm'
uses: actions/upload-artifact@v4
with:
name: dangerzone.deb
path: "deb_dist/dangerzone_*_*.deb"
if-no-files-found: error
compression-level: 0
install-deb:
name: "install-deb (${{ matrix.distro }} ${{ matrix.version }})"
runs-on: ubuntu-latest
needs:
- build-deb
strategy:
matrix:
include:
- distro: ubuntu
version: "22.04"
- distro: ubuntu
version: "24.04"
- distro: ubuntu
version: "24.10"
- distro: ubuntu
version: "25.04"
- distro: debian
version: bullseye
- distro: debian
version: bookworm
- distro: debian
version: trixie
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Download Dangerzone .deb
uses: actions/download-artifact@v4
with:
name: dangerzone.deb
path: "deb_dist/"
- name: Build end-user environment
run: |
./dev_scripts/env.py --distro ${{ matrix.distro }} \
--version ${{ matrix.version }} \
build
- name: Run a test command
run: |
./dev_scripts/env.py --distro ${{ matrix.distro }} \
--version ${{ matrix.version }} \
run dangerzone-cli dangerzone/tests/test_docs/sample-pdf.pdf --ocr-lang eng
- name: Check that the Dangerzone GUI imports work
run: |
./dev_scripts/env.py --distro ${{ matrix.distro }} \
--version ${{ matrix.version }} \
run dangerzone --help
build-install-rpm:
name: "build-install-rpm (${{ matrix.distro }} ${{matrix.version}})"
runs-on: ubuntu-latest
needs:
- build-container-image
strategy:
matrix:
distro: ["fedora"]
version: ["40", "41", "42"]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to GHCR
run: |
echo ${{ github.token }} | podman login ghcr.io -u USERNAME --password-stdin
- name: Get the dev environment
run: |
./dev_scripts/env.py \
--distro ${{ matrix.distro }} \
--version ${{ matrix.version }} \
build-dev --sync
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Restore container image
uses: actions/cache/restore@v4
with:
key: v5-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/*.py', 'dangerzone/container_helpers/*', 'install/common/build-image.py') }}
path: |-
share/container.tar
share/image-id.txt
fail-on-cache-miss: true
- name: Build Dangerzone .rpm
run: |
./dev_scripts/env.py --distro ${{ matrix.distro }} --version ${{ matrix.version }} \
run --dev --no-gui ./dangerzone/install/linux/build-rpm.py
- name: Upload Dangerzone .rpm
uses: actions/upload-artifact@v4
with:
name: dangerzone-${{ matrix.distro }}-${{ matrix.version }}.rpm
path: "dist/dangerzone-*.x86_64.rpm"
if-no-files-found: error
compression-level: 0
# Reclaim some space in this step, now that the dev environment is no
# longer necessary. Previously, we encountered out-of-space issues while
# running this CI job.
- name: Reclaim some storage space
run: podman system reset -f
- name: Build end-user environment
run: |
./dev_scripts/env.py --distro ${{ matrix.distro }} \
--version ${{ matrix.version }} \
build
- name: Run a test command
run: |
./dev_scripts/env.py --distro ${{ matrix.distro }} --version ${{ matrix.version }} \
run dangerzone-cli dangerzone/tests/test_docs/sample-pdf.pdf --ocr-lang eng
- name: Check that the Dangerzone GUI imports work
run: |
./dev_scripts/env.py --distro ${{ matrix.distro }} --version ${{ matrix.version }} \
run dangerzone --help
run-tests:
name: "run tests (${{ matrix.distro }} ${{ matrix.version }})"
runs-on: ubuntu-latest
needs:
- build-container-image
- download-tessdata
strategy:
matrix:
include:
- distro: ubuntu
version: "22.04"
- distro: ubuntu
version: "24.04"
- distro: ubuntu
version: "24.10"
- distro: ubuntu
version: "25.04"
- distro: debian
version: bullseye
- distro: debian
version: bookworm
- distro: debian
version: trixie
- distro: fedora
version: "40"
- distro: fedora
version: "41"
- distro: fedora
version: "42"
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Login to GHCR
run: |
echo ${{ github.token }} | podman login ghcr.io -u USERNAME --password-stdin
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Get the dev environment
run: |
./dev_scripts/env.py \
--distro ${{ matrix.distro }} \
--version ${{ matrix.version }} \
build-dev --sync
- name: Restore container image
uses: actions/cache/restore@v4
with:
key: v5-${{ steps.date.outputs.date }}-${{ hashFiles('Dockerfile', 'dangerzone/conversion/*.py', 'dangerzone/container_helpers/*', 'install/common/build-image.py') }}
path: |-
share/container.tar
share/image-id.txt
fail-on-cache-miss: true
- name: Restore cached tessdata
uses: actions/cache/restore@v4
with:
path: share/tessdata/
enableCrossOsArchive: true
fail-on-cache-miss: true
key: v1-tessdata-${{ hashFiles('./install/common/download-tessdata.py') }}
- name: Setup xvfb (Linux)
run: |
sudo apt update
# Stuff copied wildly from several stackoverflow posts
sudo apt-get install -y xvfb libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xinput0 libxcb-xfixes0 libxcb-shape0 libglib2.0-0 libgl1-mesa-dev '^libxcb.*-dev' libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev
# start xvfb in the background
sudo /usr/bin/Xvfb $DISPLAY -screen 0 1280x1024x24 &
- name: Run CI tests
run: |-
# Pass the -ac Xserver flag, to disable host-based access controls.
# This should be used ONLY for testing [1]. If we don't pass this
# flag, the Podman container is not authorized [2] to access the Xvfb
# server.
#
# [1] From https://www.x.org/releases/X11R6.7.0/doc/Xserver.1.html#sect4:
#
# disables host-based access control mechanisms. Enables access by
# any host, and permits any host to modify the access control
# list. Use with extreme caution. This option exists primarily for
# running test suites remotely.
#
# [2] Fails with "Authorization required, but no authorization
# protocol specified". However, we have verified with strace(1)
# that the command in the Podman container can read the Xauthority
# file successfully.
xvfb-run -s '-ac' ./dev_scripts/env.py --distro ${{ matrix.distro }} --version ${{ matrix.version }} run --dev \
bash -c 'cd dangerzone; poetry run make test'
- name: Upload PDF diffs
uses: actions/upload-artifact@v4
with:
name: pdf-diffs-${{ matrix.distro }}-${{ matrix.version }}
path: tests/test_docs/diffs/*.jpeg
# Always run this step to publish test results, even on failures
if: ${{ always() }}

View file

@ -1,22 +0,0 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v9
with:
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: "stale"
stale-issue-message: "Marking this issue as stale because it has been open for 30 days with no activity. It will be closed in 14 days if there's no activity, or if the `stale` label is not removed. Does anyone want to add something?"
close-issue-message: "Closing this issue now. Don't hesitate to reopen if you have anything to add :-)"
days-before-pr-stale: -1
days-before-pr-close: -1
repo-token: ${{ secrets.GITHUB_TOKEN }}
any-of-labels: needs info

View file

@ -1,22 +0,0 @@
name: Release multi-arch container image
on:
workflow_dispatch:
push:
branches:
- main
- "test/**"
schedule:
- cron: "0 0 * * *" # Run every day at 00:00 UTC.
jobs:
build-push-image:
uses: ./.github/workflows/build-push-image.yml
with:
registry: ghcr.io/${{ github.repository_owner }}
registry_user: ${{ github.actor }}
image_name: dangerzone/dangerzone
reproduce: true
secrets:
registry_token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,91 +0,0 @@
name: Scan latest app and container
on:
push:
branches:
- main
pull_request:
schedule:
- cron: '0 0 * * *' # Run every day at 00:00 UTC.
workflow_dispatch:
jobs:
security-scan-container:
strategy:
matrix:
runs-on:
- ubuntu-24.04
- ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build container image
run: |
python3 ./install/common/build-image.py \
--debian-archive-date $(date "+%Y%m%d") \
--runtime docker
docker load -i share/container.tar
- name: Get image tag
id: tag
run: echo "tag=$(cat share/image-id.txt)" >> $GITHUB_OUTPUT
# NOTE: Scan first without failing, else we won't be able to read the scan
# report.
- name: Scan container image (no fail)
uses: anchore/scan-action@v6
id: scan_container
with:
image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}"
fail-build: false
only-fixed: false
severity-cutoff: critical
- name: Upload container scan report
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ${{ steps.scan_container.outputs.sarif }}
category: container
- name: Inspect container scan report
run: cat ${{ steps.scan_container.outputs.sarif }}
- name: Scan container image
uses: anchore/scan-action@v6
with:
image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}"
fail-build: true
only-fixed: false
severity-cutoff: critical
security-scan-app:
strategy:
matrix:
runs-on:
- ubuntu-24.04
- ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Checkout
uses: actions/checkout@v4
# NOTE: Scan first without failing, else we won't be able to read the scan
# report.
- name: Scan application (no fail)
uses: anchore/scan-action@v6
id: scan_app
with:
path: "."
fail-build: false
only-fixed: false
severity-cutoff: critical
- name: Upload application scan report
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ${{ steps.scan_app.outputs.sarif }}
category: app
- name: Inspect application scan report
run: cat ${{ steps.scan_app.outputs.sarif }}
- name: Scan application
uses: anchore/scan-action@v6
with:
path: "."
fail-build: true
only-fixed: false
severity-cutoff: critical

View file

@ -1,99 +0,0 @@
name: Scan released app and container
on:
schedule:
- cron: '0 0 * * *' # Run every day at 00:00 UTC.
workflow_dispatch:
jobs:
security-scan-container:
strategy:
matrix:
include:
- runs-on: ubuntu-24.04
arch: i686
- runs-on: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.runs-on }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download container image for the latest release and load it
run: |
VERSION=$(curl https://api.github.com/repos/freedomofpress/dangerzone/releases/latest | grep "tag_name" | cut -d '"' -f 4)
CONTAINER_FILENAME=container-${VERSION:1}-${{ matrix.arch }}.tar
wget https://github.com/freedomofpress/dangerzone/releases/download/${VERSION}/${CONTAINER_FILENAME} -O ${CONTAINER_FILENAME}
docker load -i ${CONTAINER_FILENAME}
- name: Get image tag
id: tag
run: |
tag=$(docker images dangerzone.rocks/dangerzone --format '{{ .Tag }}')
echo "tag=$tag" >> $GITHUB_OUTPUT
# NOTE: Scan first without failing, else we won't be able to read the scan
# report.
- name: Scan container image (no fail)
uses: anchore/scan-action@v6
id: scan_container
with:
image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}"
fail-build: false
only-fixed: false
severity-cutoff: critical
- name: Upload container scan report
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ${{ steps.scan_container.outputs.sarif }}
category: container-${{ matrix.arch }}
- name: Inspect container scan report
run: cat ${{ steps.scan_container.outputs.sarif }}
- name: Scan container image
uses: anchore/scan-action@v6
with:
image: "dangerzone.rocks/dangerzone:${{ steps.tag.outputs.tag }}"
fail-build: true
only-fixed: false
severity-cutoff: critical
security-scan-app:
strategy:
matrix:
runs-on:
- ubuntu-24.04
- ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Checkout the latest released tag
run: |
# Grab the latest Grype ignore list before git checkout overwrites it.
cp .grype.yaml .grype.yaml.new
VERSION=$(curl https://api.github.com/repos/freedomofpress/dangerzone/releases/latest | jq -r '.tag_name')
git checkout $VERSION
# Restore the newest Grype ignore list.
mv .grype.yaml.new .grype.yaml
# NOTE: Scan first without failing, else we won't be able to read the scan
# report.
- name: Scan application (no fail)
uses: anchore/scan-action@v6
id: scan_app
with:
path: "."
fail-build: false
only-fixed: false
severity-cutoff: critical
- name: Upload application scan report
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ${{ steps.scan_app.outputs.sarif }}
category: app
- name: Inspect application scan report
run: cat ${{ steps.scan_app.outputs.sarif }}
- name: Scan application
uses: anchore/scan-action@v6
with:
path: "."
fail-build: true
only-fixed: false
severity-cutoff: critical

15
.gitignore vendored
View file

@ -22,7 +22,6 @@ var/
wheels/ wheels/
pip-wheel-metadata/ pip-wheel-metadata/
share/python-wheels/ share/python-wheels/
share/tessdata/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
@ -128,25 +127,13 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# Debian packaging
debian/.debhelper
debian/dangerzone
debian/files
debian/debhelper-build-stamp
debian/dangerzone.*
.pybuild/
# Other # Other
.vscode .vscode
*.tar.gz *.tar.gz
deb_dist deb_dist
.DS_Store .DS_Store
tests/test_docs/**/*-safe.pdf
tests/test_docs_large/
install/windows/Dangerzone.wxs install/windows/Dangerzone.wxs
test_docs/sample-safe.pdf
share/container.tar share/container.tar
share/container.tar.gz share/container.tar.gz
share/image-id.txt share/image-id.txt
container/container-pip-requirements.txt
.doit.db.db

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "tests/test_docs_large"]
path = tests/test_docs_large
url = https://github.com/freedomofpress/dangerzone-test-set

View file

@ -1,56 +0,0 @@
# This configuration file will be used to track CVEs that we can ignore for the
# latest release of Dangerzone, and offer our analysis.
ignore:
# CVE-2023-45853
# ==============
#
# Debian tracker: https://security-tracker.debian.org/tracker/CVE-2023-45853
# Verdict: Dangerzone is not affected because the zlib library in Debian is
# built in a way that is not vulnerable.
- vulnerability: CVE-2023-45853
# CVE-2024-38428
# ==============
#
# Debian tracker: https://security-tracker.debian.org/tracker/CVE-2024-38428
# Verdict: Dangerzone is not affected because it doesn't use wget in the
# container image (which also has no network connectivity).
- vulnerability: CVE-2024-38428
# CVE-2024-57823
# ==============
#
# Debian tracker: https://security-tracker.debian.org/tracker/CVE-2024-57823
# Verdict: Dangerzone is not affected. First things first, LibreOffice is
# using this library for parsing RDF metadata in a document [1], and has
# issued a fix for the vendored raptor2 package they have for other distros
# [2].
#
# On the other hand, the Debian security team has stated that this is a minor
# issue [3], and there's no fix from the developers yet. It seems that the
# Debian package is not affected somehow by this CVE, probably due to the way
# it's packaged.
#
# [1] https://wiki.documentfoundation.org/Documentation/DevGuide/Office_Development#RDF_metadata
# [2] https://cgit.freedesktop.org/libreoffice/core/commit/?id=2b50dc0e4482ac0ad27d69147b4175e05af4fba4
# [2] From https://security-tracker.debian.org/tracker/CVE-2024-57823:
#
# [bookworm] - raptor2 <postponed> (Minor issue, revisit when fixed upstream)
#
- vulnerability: CVE-2024-57823
# CVE-2025-0665
# ==============
#
# Debian tracker: https://security-tracker.debian.org/tracker/CVE-2025-0665
# Verdict: Dangerzone is not affected because the vulnerable code is not
# present in Debian Bookworm. Also, libcurl is an HTTP client, and the
# Dangerzone container does not make any network calls.
- vulnerability: CVE-2025-0665
# CVE-2025-43859
# ==============
#
# GitHub advisory: https://github.com/advisories/GHSA-vqfr-h8mv-ghfj
# Verdict: Dangerzone is not affected because the vulnerable code is triggered
# when parsing HTTP requests, e.g., by web **servers**. Dangerzone on the
# other hand performs HTTP requests, i.e., it operates as **client**.
- vulnerability: CVE-2025-43859
- vulnerability: GHSA-vqfr-h8mv-ghfj

View file

@ -1 +0,0 @@
https://dangerzone.rocks/assets/json/funding.json

358
BUILD.md
View file

@ -4,93 +4,19 @@
Install dependencies: Install dependencies:
<table>
<tr>
<td>
<details>
<summary><i>:memo: Expand this section if you are on Ubuntu 22.04 (Jammy).</i></summary>
</br>
The `conmon` version that Podman uses and Ubuntu Jammy ships, has a bug
that gets triggered by Dangerzone
(more details in https://github.com/freedomofpress/dangerzone/issues/685).
If you want to run Dangerzone from source, you are advised to install a
patched `conmon` version. A simple way to do so is to enable our
apt-tools-prod repo, just for the `conmon` package:
```bash
sudo cp ./dev_scripts/apt-tools-prod.sources /etc/apt/sources.list.d/
sudo cp ./dev_scripts/apt-tools-prod.pref /etc/apt/preferences.d/
```
The `conmon` package provided in the above repo was built with the
following [instructions](https://github.com/freedomofpress/maint-dangerzone-conmon/tree/ubuntu/jammy/fpf).
Alternatively, you can install a `conmon` version higher than `v2.0.25` from
any repo you prefer.
</details>
</td>
</tr>
</table>
```sh ```sh
sudo apt install -y podman dh-python build-essential make libqt6gui6 \ sudo apt install -y podman dh-python python3 python3-stdeb python3-pyside2.qtcore python3-pyside2.qtgui python3-pyside2.qtwidgets python3-appdirs python3-click python3-xdg python3-requests python3-colorama python3-psutil
pipx python3 python3-dev
```
Install Poetry using `pipx` (recommended) and add it to your `$PATH`:
_(See also a list of [alternative installation
methods](https://python-poetry.org/docs/#installation))_
```sh
pipx ensurepath
pipx install poetry
pipx inject poetry poetry-plugin-export
```
After this, restart the terminal window, for the `poetry` command to be in your
`$PATH`.
Clone this repository:
```
git clone https://github.com/freedomofpress/dangerzone/
```
Change to the `dangerzone` folder, and install the poetry dependencies:
> **Note**: due to an issue with [poetry](https://github.com/python-poetry/poetry/issues/1917), if it prompts for your keyring, disable the keyring with `keyring --disable` and run the command again.
```
cd dangerzone
poetry install
``` ```
Build the latest container: Build the latest container:
```sh ```sh
python3 ./install/common/build-image.py ./install/linux/build-image.sh
```
Download the OCR language data:
```sh
python3 ./install/common/download-tessdata.py
``` ```
Run from source tree: Run from source tree:
```sh ```sh
# start a shell in the virtual environment
poetry shell
# run the CLI
./dev_scripts/dangerzone-cli --help
# run the GUI
./dev_scripts/dangerzone ./dev_scripts/dangerzone
``` ```
@ -105,228 +31,39 @@ Create a .deb:
Install dependencies: Install dependencies:
```sh ```sh
sudo dnf install -y rpm-build podman python3 python3-devel python3-poetry-core \ sudo dnf install -y rpm-build podman python3 python3-setuptools python3-pyside2 python3-appdirs python3-click python3-pyxdg python3-requests python3-colorama python3-psutil
pipx qt6-qtbase-gui
```
Install Poetry using `pipx`:
```sh
pipx install poetry
pipx inject poetry
```
Clone this repository:
```
git clone https://github.com/freedomofpress/dangerzone/
```
Change to the `dangerzone` folder, and install the poetry dependencies:
> **Note**: due to an issue with [poetry](https://github.com/python-poetry/poetry/issues/1917), if it prompts for your keyring, disable the keyring with `keyring --disable` and run the command again.
```
cd dangerzone
poetry install
``` ```
Build the latest container: Build the latest container:
```sh ```sh
python3 ./install/common/build-image.py ./install/linux/build-image.sh
```
Download the OCR language data:
```sh
python3 ./install/common/download-tessdata.py
``` ```
Run from source tree: Run from source tree:
```sh ```sh
# start a shell in the virtual environment
poetry shell
# run the CLI
./dev_scripts/dangerzone-cli --help
# run the GUI
./dev_scripts/dangerzone ./dev_scripts/dangerzone
``` ```
> [!NOTE]
> Prefer running the following command in a Fedora development environment,
> created by `./dev_script/env.py`.
Create a .rpm: Create a .rpm:
```sh ```sh
./install/linux/build-rpm.py ./install/linux/build-rpm.py
``` ```
## Qubes OS
> :warning: Native Qubes support is in beta stage, so the instructions below
> require switching between qubes, and are subject to change.
>
> If you want to build Dangerzone on Qubes and use containers instead of disposable
> qubes, please follow the instructions of Fedora / Debian instead.
### Initial Setup
The following steps must be completed once. Make sure you run them in the
specified qubes.
Overview of the qubes you'll create:
| qube | type | purpose |
|--------------|----------|---------|
| dz | app qube | Dangerzone development |
| dz-dvm | app qube | offline disposable template for performing conversions |
| fedora-41-dz | template | template for the other two qubes |
#### In `dom0`:
The following instructions require typing commands in a terminal in dom0.
1. Create a new Fedora **template** (`fedora-41-dz`) for Dangerzone development:
```
qvm-clone fedora-41 fedora-41-dz
```
> :bulb: Alternatively, you can use your base Fedora 40 template in the
> following instructions. In that case, skip this step and replace
> `fedora-41-dz` with `fedora-41` in the steps below.
2. Create an offline disposable template (app qube) called `dz-dvm`, based on the `fedora-41-dz`
template. This will be the qube where the documents will be sanitized:
```
qvm-create --class AppVM --label red --template fedora-41-dz \
--prop netvm="" --prop template_for_dispvms=True \
--prop default_dispvm='' dz-dvm
```
3. Create an **app** qube (`dz`) that will be used for Dangerzone development
and initiating the sanitization process:
```
qvm-create --class AppVM --label red --template fedora-41-dz dz
qvm-volume resize dz:private $(numfmt --from=auto 20Gi)
```
> :bulb: Alternatively, you can use a different app qube for Dangerzone
> development. In that case, replace `dz` with the qube of your choice in the
> steps below.
>
> In the commands above, we also resize the private volume of the `dz` qube
> to 20GiB, since you may need some extra storage space when developing on
> Dangerzone (e.g., for container images, Tesseract data, and Python
> virtualenvs).
4. Add an RPC policy (`/etc/qubes/policy.d/50-dangerzone.policy`) that will
allow launching a disposable qube (`dz-dvm`) when Dangerzone converts a
document, with the following contents:
```
dz.Convert * @anyvm @dispvm:dz-dvm allow
dz.ConvertDev * @anyvm @dispvm:dz-dvm allow
```
#### In the `dz` app qube
In the following steps you'll setup the development environment and
install a dangerzone build. This will make the development faster since it
loads the server code dynamically each time it's run, instead of having
to build and install a server package each time the developer wants to
test it.
1. Clone the Dangerzone project:
```
git clone https://github.com/freedomofpress/dangerzone
cd dangerzone
```
2. Follow the Fedora instructions for setting up the development environment.
3. Build a dangerzone `.rpm` for qubes with the command
```sh
./install/linux/build-rpm.py --qubes
```
4. Copy the produced `.rpm` file into `fedora-41-dz`
```sh
qvm-copy dist/*.x86_64.rpm
```
#### In the `fedora-41-dz` template
1. Install the `.rpm` package you just copied
```sh
sudo dnf install ~/QubesIncoming/dz/*.rpm
```
2. Shutdown the `fedora-41-dz` template
### Developing Dangerzone
From here on, developing Dangerzone is similar to Fedora. The only differences
are that you need to set the environment variable `QUBES_CONVERSION=1` when
you wish to test the Qubes conversion, run the following commands on the `dz` development qube:
```sh
# run the CLI
QUBES_CONVERSION=1 poetry run ./dev_scripts/dangerzone-cli --help
# run the GUI
QUBES_CONVERSION=1 poetry run ./dev_scripts/dangerzone
```
And when creating a `.rpm` you'll need to enable the `--qubes` flag.
> [!NOTE]
> Prefer running the following command in a Fedora development environment,
> created by `./dev_script/env.py`.
```sh
./install/linux/build-rpm.py --qubes
```
For changes in the server side components, you can simply edit them locally,
and they will be mirrored to the disposable qube through the `dz.ConvertDev`
RPC call.
The only reason to build a new Qubes RPM and install it in the `fedora-41-dz`
template for development is if:
1. The project requires new server-side components.
2. The code for `qubes/dz.ConvertDev` needs to be updated.
## macOS ## macOS
Install Xcode from the App Store.
Install [Docker Desktop](https://www.docker.com/products/docker-desktop). Make sure to choose your correct CPU, either Intel Chip or Apple Chip. Install [Docker Desktop](https://www.docker.com/products/docker-desktop). Make sure to choose your correct CPU, either Intel Chip or Apple Chip.
Install the latest version of Python 3.12 [from python.org](https://www.python.org/downloads/macos/), and make sure `/Library/Frameworks/Python.framework/Versions/3.12/bin` is in your `PATH`. Install Python 3.9.9 [from python.org](https://www.python.org/downloads/release/python-399/).
Clone this repository:
```
git clone https://github.com/freedomofpress/dangerzone/
cd dangerzone
```
Install Python dependencies: Install Python dependencies:
```sh ```sh
python3 -m pip install poetry pip3 install --user poetry
poetry install poetry install
``` ```
@ -339,13 +76,7 @@ brew install create-dmg
Build the dangerzone container image: Build the dangerzone container image:
```sh ```sh
python3 ./install/common/build-image.py ./install/macos/build-image.sh
```
Download the OCR language data:
```sh
python3 ./install/common/download-tessdata.py
``` ```
Run from source tree: Run from source tree:
@ -379,73 +110,47 @@ The output is in the `dist` folder.
Install [Docker Desktop](https://www.docker.com/products/docker-desktop). Install [Docker Desktop](https://www.docker.com/products/docker-desktop).
Install the latest version of Python 3.12 (64-bit) [from python.org](https://www.python.org/downloads/windows/). Make sure to check the "Add Python 3.12 to PATH" checkbox on the first page of the installer. Install Python 3.9.9 (x86) [from python.org](https://www.python.org/downloads/release/python-399/). When installing it, make sure to check the "Add Python 3.9 to PATH" checkbox on the first page of the installer.
Install Microsoft Visual C++ 14.0 or greater. Get it with ["Microsoft C++ Build Tools"](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and make sure to select "Desktop development with C++" when installing.
Install [poetry](https://python-poetry.org/). Open PowerShell, and run: Install [poetry](https://python-poetry.org/). Open PowerShell, and run:
``` ```
python -m pip install poetry (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py -UseBasicParsing).Content | python
```
Install git from [here](https://git-scm.com/download/win), open a Windows terminal (`cmd.exe`) and clone this repository:
```
git clone https://github.com/freedomofpress/dangerzone/
``` ```
Change to the `dangerzone` folder, and install the poetry dependencies: Change to the `dangerzone` folder, and install the poetry dependencies:
``` ```
cd dangerzone
poetry install poetry install
``` ```
Build the dangerzone container image: Build the dangerzone container image:
```sh ```sh
python3 .\install\common\build-image.py python .\install\windows\build-image.py
```
Download the OCR language data:
```sh
python3 .\install\common\download-tessdata.py
``` ```
After that you can launch dangerzone during development with: After that you can launch dangerzone during development with:
``` ```
# start a shell in the virtual environment
poetry shell
# run the CLI
.\dev_scripts\dangerzone-cli.bat --help
# run the GUI
.\dev_scripts\dangerzone.bat .\dev_scripts\dangerzone.bat
.\dev_scripts\dangerzone-cli.bat --help
``` ```
### If you want to build the Windows installer ### If you want to build a .exe
Install [.NET SDK](https://dotnet.microsoft.com/en-us/download) version 6 or later. Then, open a terminal and install the latest version of [WiX Toolset .NET tool](https://wixtoolset.org/) **v5** with: Download and install the [Windows 10 SDK](https://developer.microsoft.com/en-US/windows/downloads/windows-10-sdk/).
```sh Add the following directories to the path:
dotnet tool install --global wix --version 5.0.2
```
Install the WiX UI extension. You may need to open a new terminal in order to use the newly installed `wix` .NET tool: * `C:\Program Files (x86)\Windows Kits\10\bin\10.0.19041.0\x86`
* `C:\Program Files (x86)\Windows Kits\10\Redist\ucrt\DLLs\x86`
```sh ### If you want to build the installer
wix extension add --global WixToolset.UI.wixext/5.0.2
```
> [!IMPORTANT] * Go to https://dotnet.microsoft.com/download/dotnet-framework and download and install .NET Framework 3.5 SP1 Runtime. I downloaded `dotnetfx35.exe`.
> To avoid compatibility issues, ensure the WiX UI extension version matches the version of the WiX Toolset. * Go to https://wixtoolset.org/releases/ and download and install WiX toolset. I downloaded `wix311.exe`.
> * Add `C:\Program Files (x86)\WiX Toolset v3.11\bin` to the path.
> Run `wix --version` to check the version of WiX Toolset you have installed and replace `5.x.y` with the full version number without the Git revision.
### If you want to sign binaries with Authenticode ### If you want to sign binaries with Authenticode
@ -456,23 +161,24 @@ You'll need a code signing certificate.
Open a command prompt, cd into the dangerzone directory, and run: Open a command prompt, cd into the dangerzone directory, and run:
``` ```
poetry run python .\setup-windows.py build poetry run pyinstaller install\pyinstaller\pyinstaller.spec
``` ```
In `build\exe.win32-3.12\` you will find `dangerzone.exe`, `dangerzone-cli.exe`, and all supporting files. `dangerzone.exe` and all of their supporting files will get created inside the `dist` folder.
You then must create a symbolic link for dangerzone to run. To do this, open a command prompt _as an administrator_, cd to the dangerzone folder, and run:
```
cd dist\dangerzone
mklink dangerzone-container.exe dangerzone.exe
```
### To build the installer ### To build the installer
Note that you must have a codesigning certificate installed in order to use the `install\windows\build-app.bat` script, because it codesigns `dangerzone.exe`, `dangerzone-cli.exe` and `Dangerzone.msi`. Note that you must have a codesigning certificate installed in order to use the `install\windows\build-app.bat` script, because it codesigns `dangerzone.exe` and `Dangerzone.msi`.
``` ```
poetry run .\install\windows\build-app.bat poetry run .\install\windows\build-app.bat
``` ```
When you're done you will have `dist\Dangerzone.msi`. When you're done you will have `dist\Dangerzone.msi`.
## Updating the container image
The Dangezone container image is reproducible. This means that every time we
build it, the result will be bit-for-bit the same, with some minor exceptions.
Read more on how you can update it in `docs/developer/reproducibility.md`.

View file

@ -1,433 +1,9 @@
# Changelog # Change Log
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
since 0.4.1, and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased](https://github.com/freedomofpress/dangerzone/compare/v0.9.0...HEAD)
## Changed
- Update installation instructions (and CI checks) for Debian derivatives ([#1141](https://github.com/freedomofpress/dangerzone/pull/1141))
## [0.9.0](https://github.com/freedomofpress/dangerzone/compare/v0.9.0...0.8.1)
### Added
- Platform support: Add support for Fedora 42 ([#1091](https://github.com/freedomofpress/dangerzone/issues/1091))
- Platform support: Add support for Ubuntu 25.04 (Plucky Puffin) ([#1090](https://github.com/freedomofpress/dangerzone/issues/1090))
- (experimental): It is now possible to specify a custom container runtime in
the settings, by using the `container_runtime` key. It should contain the path
to the container runtime you want to use. Please note that this doesn't mean
we support more container runtimes than Podman and Docker for the time being,
but enables you to chose which one you want to use, independently of your
platform. ([#925](https://github.com/freedomofpress/dangerzone/issues/925))
- Document Operating System support [#986](https://github.com/freedomofpress/dangerzone/issues/986)
- Tests: Look for regressions when converting PDFs [#321](https://github.com/freedomofpress/dangerzone/issues/321)
- Ensure container image reproducibilty across different container runtimes and versions ([#1074](https://github.com/freedomofpress/dangerzone/issues/1074))
- Implement container image attestations ([#1035](https://github.com/freedomofpress/dangerzone/issues/1035))
- Inform user of outdated Docker Desktop Version ([#693](https://github.com/freedomofpress/dangerzone/issues/693))
- Add support for Python 3.13 ([#992](https://github.com/freedomofpress/dangerzone/issues/992))
- Publish the built artifacts in our CI pipelines ([#972](https://github.com/freedomofpress/dangerzone/pull/972))
### Fixed
- Fix our Debian Trixie installation instructions using Sequoia PGP ([#1052](https://github.com/freedomofpress/dangerzone/issues/1052))
- Fix the way multiprocessing works on macOS ([#873](https://github.com/freedomofpress/dangerzone/issues/873))
- Update minimum Docker Desktop version to fix an stdout truncation issue ([#1101](https://github.com/freedomofpress/dangerzone/issues/1101))
### Removed
- Platform support: Drop support for Ubuntu Focal, since it's nearing end-of-life ([#1018](https://github.com/freedomofpress/dangerzone/issues/1018))
- Platform support: Drop support for Fedora 39 ([#999](https://github.com/freedomofpress/dangerzone/issues/999))
## Changed
- Switch base image to Debian Stable ([#1046](https://github.com/freedomofpress/dangerzone/issues/1046))
- Track image tags instead of image IDs in `image-id.txt` ([#1020](https://github.com/freedomofpress/dangerzone/issues/1020))
- Migrate to Wix 4 (windows building tool) ([#602](https://github.com/freedomofpress/dangerzone/issues/602)).
Thanks [@jkarasti](https://github.com/jkarasti) for the contribution.
- Add a `--debug` flag to the CLI to help retrieve more logs ([#941](https://github.com/freedomofpress/dangerzone/pull/941))
- The `debian` base image is now fetched by digest. As a result, your local
container storage will no longer show a tag for this dependency
([#1116](https://github.com/freedomofpress/dangerzone/pull/1116)).
Thanks [@sudoforge](https://github.com/sudoforge) for the contribution.
- The `debian` base image is now referenced with a fully qualified URI,
including the registry hostname ([#1118](https://github.com/freedomofpress/dangerzone/pull/1118)).
Thanks [@sudoforge](https://github.com/sudoforge) for the contribution.
- Update the Dangerzone container image and its dependencies (gVisor, Debian base image, H2Orestart) to the latest versions:
* Debian image release: `bookworm-20250317-slim@sha256:1209d8fd77def86ceb6663deef7956481cc6c14a25e1e64daec12c0ceffcc19d`
* Debian snapshots date: `2025-03-31`
* gVisor release date: `2025-03-26`
* H2Orestart plugin: `v0.7.2` (`d09bc5c93fe2483a7e4a57985d2a8d0e4efae2efb04375fe4b59a68afd7241e2`)
### Development changes
- Make container image scanning work for Silicon macOS ([#1008](https://github.com/freedomofpress/dangerzone/issues/1008))
- Automate the main bulk of our release tasks ([#1016](https://github.com/freedomofpress/dangerzone/issues/1016))
- CI: Enforce updating the CHANGELOG in the CI ([#1108](https://github.com/freedomofpress/dangerzone/pull/1108))
- Add reference to funding.json (required by floss.fund application) ([#1092](https://github.com/freedomofpress/dangerzone/pull/1092))
- Lint: add ruff for linting and formatting ([#1029](https://github.com/freedomofpress/dangerzone/pull/1029)).
Thanks [@jkarasti](https://github.com/jkarasti) for the contribution.
- Work around a `cx_freeze` build issue ([#974](https://github.com/freedomofpress/dangerzone/issues/974))
- tests: mark the hancom office suite tests for rerun on failures ([#991](https://github.com/freedomofpress/dangerzone/pull/991))
- Update reference template for Qubes to Fedora 41 ([#1078](https://github.com/freedomofpress/dangerzone/issues/1078))
## [0.8.1](https://github.com/freedomofpress/dangerzone/compare/v0.8.1...0.8.0)
- Update the container image
### Added
- Disable gVisor's DirectFS feature ([#226](https://github.com/freedomofpress/dangerzone/issues/226)).
Thanks [EtiennePerot](https://github.com/EtiennePerot) for the contribution.
### Removed
- Platform support: Drop support for Fedora 39, since it's end-of-life ([#999](https://github.com/freedomofpress/dangerzone/pull/999))
## Updated
- Bump `slsa-framework/slsa-github-generator` from 2.0.0 to 2.1.0 ([#1109](https://github.com/freedomofpress/dangerzone/pull/1109))
### Development changes
Thanks [@jkarasti](https://github.com/jkarasti) for the contribution.
- Automate a large portion of our release tasks with `doit` ([#1016](https://github.com/freedomofpress/dangerzone/issues/1016))
## [0.8.0](https://github.com/freedomofpress/dangerzone/compare/v0.8.0...0.7.1)
### Added
- Point to the installation instructions that the Tails team maintains for Dangerzone ([announcement](https://tails.net/news/dangerzone/index.en.html))
- Installation and execution errors are now caught and displayed in the interface ([#193](https://github.com/freedomofpress/dangerzone/issues/193))
- Prevent users from using illegal characters in output filename ([#362](https://github.com/freedomofpress/dangerzone/issues/362)). Thanks [@bnewc](https://github.com/bnewc) for the contribution!
- Add support for Fedora 41 ([#947](https://github.com/freedomofpress/dangerzone/issues/947))
- Add support for Ubuntu Oracular (24.10) ([#954](https://github.com/freedomofpress/dangerzone/pull/954))
### Fixed
- Update our macOS entitlements, removing now unneeded privileges ([#638](https://github.com/freedomofpress/dangerzone/issues/638))
- Make Dangerzone work on Linux systems with SELinux in enforcing mode ([#880](https://github.com/freedomofpress/dangerzone/issues/880))
- Process documents with embedded multimedia files without crashing ([#877](https://github.com/freedomofpress/dangerzone/issues/877))
- Search for applications that can read PDF files in a more reliable way on Linux ([#899](https://github.com/freedomofpress/dangerzone/issues/899))
- Handle and report some stray conversion errors ([#776](https://github.com/freedomofpress/dangerzone/issues/776)). Thanks [@amnak613](https://github.com/amnak613) for the contribution!
- Replace occurrences of the word "Docker" in Podman-related error messages in Linux ([#212](https://github.com/freedomofpress/dangerzone/issues/212))
### Changed
- The second phase of the conversion (pixels to PDF) now happens on the host. Instead of first grabbing all of the pixel data from the first container, storing them on disk, and then reconstructing the PDF on a second container, Dangerzone now immediately reconstructs the PDF **on the host**, while the doc to pixels conversion is still running on the first container. The sanitation is no less safe, since the boundaries between the sandbox and the host are still respected ([#625](https://github.com/freedomofpress/dangerzone/issues/625))
- PyMuPDF is now vendorized for Debian packages. This is done because the PyMuPDF package from the Debian repos lacks OCR support ([#940](https://github.com/freedomofpress/dangerzone/pull/940))
- Always use our own seccomp policy as a default ([#908](https://github.com/freedomofpress/dangerzone/issues/908))
- Debian packages are now amd64 only, which removes some warnings in Linux distros with 32-bit repos enabled ([#394](https://github.com/freedomofpress/dangerzone/issues/394))
- Allow choosing installation directory on Windows platforms ([#148](https://github.com/freedomofpress/dangerzone/issues/148)). Thanks [@jkarasti](https://github.com/jkarasti) for the contribution!
- Bumped H2ORestart LibreOffice extension to version 0.6.6 ([#943](https://github.com/freedomofpress/dangerzone/issues/943))
- Platform support: Ubuntu Focal (20.04) is now deprecated, and support will be dropped with the next release ([#965](https://github.com/freedomofpress/dangerzone/issues/965))
### Removed
- Platform support: Drop Ubuntu Mantic (23.10), since it's end-of-life ([#977](https://github.com/freedomofpress/dangerzone/pull/977))
### Development changes
- Build Debian packages with pybuild ([#773](https://github.com/freedomofpress/dangerzone/issues/773))
- Test Dangerzone on Intel macOS machines as well ([#932](https://github.com/freedomofpress/dangerzone/issues/932))
- Switch from CircleCI runners to Github actions ([#674](https://github.com/freedomofpress/dangerzone/issues/674))
- Sign Windows executables and installer with SHA256 rather than SHA1 ([#931](https://github.com/freedomofpress/dangerzone/pull/931)). Thanks [@jkarasti](https://github.com/jkarasti) for the contribution!
## [0.7.1](https://github.com/freedomofpress/dangerzone/compare/v0.7.1...v0.7.0)
### Fixed
- Fix an `image-id.txt` mismatch happening on Docker Desktop >= 4.30.0 ([#933](https://github.com/freedomofpress/dangerzone/issues/933))
## [0.7.0](https://github.com/freedomofpress/dangerzone/compare/v0.7.0...v0.6.1)
### Added
- Integrate Dangerzone with gVisor, a memory-safe application kernel, thanks to [@EtiennePerot](https://github.com/EtiennePerot) ([#126](https://github.com/freedomofpress/dangerzone/issues/126)).
As a result of this integration, we have also improved Dangerzone's security in the following ways:
* Prevent attacker from becoming root within the container ([#224](https://github.com/freedomofpress/dangerzone/issues/224))
* Use a restricted seccomp profile ([#225](https://github.com/freedomofpress/dangerzone/issues/225))
* Make use of user namespaces ([#228](https://github.com/freedomofpress/dangerzone/issues/228))
- Files can now be drag-n-dropped to Dangerzone ([issue #409](https://github.com/freedomofpress/dangerzone/issues/409))
### Fixed
- Fix a deprecation warning in PySide6, thanks to [@naglis](https://github.com/naglis) ([issue #595](https://github.com/freedomofpress/dangerzone/issues/595))
- Make update notifications work in systems with PySide2, thanks to [@naglis](https://github.com/naglis) ([issue #788](https://github.com/freedomofpress/dangerzone/issues/788))
- Updated the Dangerzone container image to use Alpine Linux 3.20 ([#812](https://github.com/freedomofpress/dangerzone/pull/812))
- Fix wrong file permissions in Fedora packages ([issue #727](https://github.com/freedomofpress/dangerzone/pull/727))
- Quote commands in installation instructions, making it compatible with `zsh` based shells. (issue [#805](https://github.com/freedomofpress/dangerzone/issues/805))
- Order the list of PDF viewers and return the default application first on Linux, thanks to [@rocodes](https://github.com/rocodes) (issue [#814](https://github.com/freedomofpress/dangerzone/pull/814))
### Removed
- Platform support: Drop Fedora 38, since it's end-of-life ([issue #840](https://github.com/freedomofpress/dangerzone/pull/840))
### Development changes
- Bumped the minimum python version to 3.9, due to Pyside6 dropping support for python 3.8 ([#780](https://github.com/freedomofpress/dangerzone/pull/780))
- Minor amendments to the codebase (in [#811](https://github.com/freedomofpress/dangerzone/pull/811))
- Use the original line ending (usually `LF`) for all content except images ([#838](https://github.com/freedomofpress/dangerzone/pull/838))
- Explained how to create, sign, and verify source tarballs ([#823](https://github.com/freedomofpress/dangerzone/pull/823))
- Added a design doc for the update notifications
- Added a design doc for the gVisor integration ([#815](https://github.com/freedomofpress/dangerzone/pull/815))
- Removed the python shebang from some files
## Dangerzone 0.6.1
### Added
- Platform support: Ubuntu 24.04 and Fedora 40 ([issue #762](https://github.com/freedomofpress/dangerzone/issues/762))
### Fixed
- Handle timeout errors (`"Timeout after 3 seconds"`) more gracefully ([issue #749](https://github.com/freedomofpress/dangerzone/issues/749))
- Make Dangerzone work in macOS versions prior to Ventura (13), thanks to [@maltfield](https://github.com/maltfield) ([issue #471](https://github.com/freedomofpress/dangerzone/issues/471))
- Make OCR work again in Qubes Fedora 38 templates ([issue #737](https://github.com/freedomofpress/dangerzone/issues/737))
- Make .svg / .bmp files selectable when browsing files via the Dangerzone GUI ([#722](https://github.com/freedomofpress/dangerzone/pull/722))
- Linux: Show the proper application name and icon for Dangerzone, in the user's window manager, thanks to [@naglis](https://github.com/naglis) ([issue #402](https://github.com/freedomofpress/dangerzone/issues/402))
- Linux: Allow opening multiple files at once, when selecting them from the user's file manager, thanks to [@naglis](https://github.com/naglis) ([issue #797](https://github.com/freedomofpress/dangerzone/issues/797))
- Linux: Do not include Dangerzone in the list of available PDF viewers, thanks to [@naglis](https://github.com/naglis) ([issue #790](https://github.com/freedomofpress/dangerzone/issues/790))
- Linux: Handle filenames with invalid Unicode characters in the Dangerzone CLI, thanks to [@naglis](https://github.com/naglis) ([issue #768](https://github.com/freedomofpress/dangerzone/issues/768))
### Changed
- Sign our release assets with the Dangerzone signing key, and provide
instructions to end-users ([issue #761](https://github.com/freedomofpress/dangerzone/issues/761)
- Use the newest reimplementation of the PyMuPDF rendering engine (`fitz`) ([issue #700](https://github.com/freedomofpress/dangerzone/issues/700))
- Development: Build Dangerzone using the latest Wix 3.14 release ([#746](https://github.com/freedomofpress/dangerzone/pull/746)
## Dangerzone 0.6.0
### Added
- Platform support: Fedora 39 ([issue #606](https://github.com/freedomofpress/dangerzone/issues/606))
- Add new file formats: epub svg and several image formats (BMP, PNM, BPM, PPM) ([issue #697](https://github.com/freedomofpress/dangerzone/issues/697))
## Fixed
- Fix mismatched between between original document and converted one ([issue #626](https://github.com/freedomofpress/dangerzone/issues/)). This does not affect the quality of the final document.
- Capitalize "dangerzone" on the application as well as on the Linux desktop shortcut, thanks to [@sudwhiwdh](https://github.com/sudwhiwdh) [#676](https://github.com/freedomofpress/dangerzone/pull/676)
- Fedora (Linux): Add missing Dangerzone logo on application launcher ([issue #645](https://github.com/freedomofpress/dangerzone/issues/645))
- Prevent document conversion from failing due to lack of space in the converter. This affected mainly systems with low computing resources such as Qubes OS ([issue #574](https://github.com/freedomofpress/dangerzone/issues/574))
- Add a missing dependency to our Apple Silicon container image, which affected dev environments only, thanks to [@prateekj117](https://github.com/prateekj117) ([#671](https://github.com/freedomofpress/dangerzone/pull/671))
- Development: Add missing check when building container image, thanks to [@EtiennePerot](https://github.com/EtiennePerot) ([#721](https://github.com/freedomofpress/dangerzone/pull/721))
### Changed
- Feature: Add support for HWP/HWPX files (Hancom Office) for macOS Apple Silicon devices ([issue #498](https://github.com/freedomofpress/dangerzone/issues/498), thanks to [@OctopusET](https://github.com/OctopusET))
- Replace Dangerzone document rendering engine from pdftoppm PyMuPDF, essentially replacing a variety of tools (gm / tesseract / pdfunite / ps2pdf) ([issue #658](https://github.com/freedomofpress/dangerzone/issues/658))
- Changed project license from MIT to AGPLv3 (related to [issue #658](https://github.com/freedomofpress/dangerzone/issues/658))
- Containers: stream pages instead of mounting directories. For users in practice this doesn't change much, but it opens up technical possibilities that go from security to usability. ([issue #443](https://github.com/freedomofpress/dangerzone/issues/443))
- Ubuntu Jammy (Linux): add external depedency (provided by the Dangerzone repository) which fixes podman crashing during standar stream I/O ([issue #685](https://github.com/freedomofpress/dangerzone/issues/685))
### Removed
- Removed timeouts ([issue #687](https://github.com/freedomofpress/dangerzone/issues/687))
- Platform support: Drop Ubuntu 23.04 (Lunar Lobster), since it's end-of-life ([issue #705](https://github.com/freedomofpress/dangerzone/issues/705))
## Dangerzone 0.5.1
### Fixed
- Our Qubes RPM package was missing critical dependencies for the conversion of a document from pixels to PDF ([issue #647](https://github.com/freedomofpress/dangerzone/issues/647))
### Changed
- Use more descriptive button labels in update check prompt ([issue #527](https://github.com/freedomofpress/dangerzone/issues/527), thanks to [@garrettr](https://github.com/garrettr))
### Removed
- Platform support: Drop Fedora 37, since it reached end-of-life ([issue #637](https://github.com/freedomofpress/dangerzone/issues/637))
### Security
- [Security advisory 2023-12-07](https://github.com/freedomofpress/dangerzone/blob/main/docs/advisories/2023-12-07.md): Protect our container image against
CVE-2023-43115, by updating GhostScript to version 10.02.0.
- [Security advisory 2023-10-25](https://github.com/freedomofpress/dangerzone/blob/main/docs/advisories/2023-10-25.md): prevent dz-dvm network via dispVMs. This was
officially communicated on the advisory date and is only included here since
this is the first release since it was announced.
## Dangerzone 0.5.0
### Added
- Platform support: Beta integration with Qubes OS ([issue #412](https://github.com/freedomofpress/dangerzone/issues/412))
- Platform support: Ubuntu 23.10 (Mantic Minotaur) ([issue #601](https://github.com/freedomofpress/dangerzone/issues/601))
- Add client-side timeouts in Qubes ([issue #446](https://github.com/freedomofpress/dangerzone/issues/446))
- Add installation instructions for Qubes ([issue #431](https://github.com/freedomofpress/dangerzone/issues/431))
- Development: Add tests that run Dangerzone against a pool of roughly 11K documents ([PR #386](https://github.com/freedomofpress/dangerzone/pull/386))
- Development: Grab the output of commands when in development mode ([issue #319](https://github.com/freedomofpress/dangerzone/issues/319))
### Fixed
- Fix a bug that was introduced in version 0.4.1 and could potentially lead to
excluding the last page of the sanitized document ([issue #560](https://github.com/freedomofpress/dangerzone/issues/560))
- Fix the parsing of a document's page count ([issue #565](https://github.com/freedomofpress/dangerzone/issues/565))
- Platform support: Fix broken Dangerzone upgrades in Fedora ([issue #514](https://github.com/freedomofpress/dangerzone/issues/514))
- Make progress reports in Qubes real-time ([issue #557](https://github.com/freedomofpress/dangerzone/issues/557))
- Improve the handling of various runtime errors in Qubes ([issue #430](https://github.com/freedomofpress/dangerzone/issues/430))
- Pass OCR parameters properly in Qubes ([issue #455](https://github.com/freedomofpress/dangerzone/issues/455))
- Fix dark mode support ([issue #550](https://github.com/freedomofpress/dangerzone/issues/550),
thanks to [@garrettr](https://github.com/garrettr))
- MacOS/Windows: Sync "Check for updates" checkbox with the user's choice ([issue #513](https://github.com/freedomofpress/dangerzone/issues/513))
- Fix issue where changing document selection to a file from a different directory would lead to an error ([issue #581](https://github.com/freedomofpress/dangerzone/issues/581))
- Qubes: in the cli version "Safe PDF created" would be shown twice ([issue #555](https://github.com/freedomofpress/dangerzone/issues/555))
- Qubes: in the cli version the percentage is now rounded to the unit ([issue #553](https://github.com/freedomofpress/dangerzone/issues/553))
- Qubes: clean up temporary files ([issue #575](https://github.com/freedomofpress/dangerzone/issues/575))
- Qubes: do not open document if the conversion failed ([issue #581](https://github.com/freedomofpress/dangerzone/issues/581))
- Development: Switch from the deprecated `bdist_rpm` toolchain to the more
modern RPM SPEC files, when building Fedora packages ([issue #298](https://github.com/freedomofpress/dangerzone/issues/298))
- Development: Make our dev scripts properly invoke Docker in MacOS / windows
([issue #519](https://github.com/freedomofpress/dangerzone/issues/519))
### Changed
- Shave off ~300MiB from our container image, using the fast variant of the
Tesseract OCR language models ([issue #545](https://github.com/freedomofpress/dangerzone/issues/545))
- When a user is asked to enable updates, make "Yes" the default option ([issue #507](https://github.com/freedomofpress/dangerzone/issues/507))
- Use Fedora 38 as a template in our Qubes build instructions ([PR #533](https://github.com/freedomofpress/dangerzone/issues/533))
- Improve the installation docs for newcomers ([issue #475](https://github.com/freedomofpress/dangerzone/issues/475))
- Development: Explain how to get the application password from the MacOS keychain ([issue #522](https://github.com/freedomofpress/dangerzone/issues/522))
### Removed
- Remove the `dangerzone-container` executable, since it was not used in practice by any user ([PR #538](https://github.com/freedomofpress/dangerzone/issues/538))
### Security
- Do not allow attackers to show error or log messages to Qubes users ([issue #456](https://github.com/freedomofpress/dangerzone/issues/456))
## Dangerzone 0.4.2
### Added
- Inform about new updates on MacOS/Windows platforms, by periodically checking
our GitHub releases page ([issue #189](https://github.com/freedomofpress/dangerzone/issues/189))
- Feature: Add support for HWP/HWPX files (Hancom Office) ([issue #243](https://github.com/freedomofpress/dangerzone/issues/243), thanks to [@OctopusET](https://github.com/OctopusET))
* **NOTE:** This feature is not yet supported on MacOS with Apple Silicon CPU
or Qubes OS ([issue #494](https://github.com/freedomofpress/dangerzone/issues/494),
[issue #498](https://github.com/freedomofpress/dangerzone/issues/498))
- Allow users to change their document selection from the UI ([issue #428](https://github.com/freedomofpress/dangerzone/issues/428))
- Add a note in our README for MacOS 11+ users blocked by SIP ([PR #401](https://github.com/freedomofpress/dangerzone/pull/401), thanks to [@keywordnew](https://github.com/keywordnew))
- Platform support: Alpha integration with Qubes OS ([issue #411](https://github.com/freedomofpress/dangerzone/issues/411))
- Platform support: Debian Trixie (13) ([issue #452](https://github.com/freedomofpress/dangerzone/issues/452))
- Platform support: Ubuntu 23.04 (Lunar Lobster) ([issue #453](https://github.com/freedomofpress/dangerzone/issues/453))
- Development: Use Qt6 in our CI runners and dev environments ([issue #482](https://github.com/freedomofpress/dangerzone/issues/482))
### Removed
- Platform support: Drop Fedora 36, since it's end-of-life ([issue #420](https://github.com/freedomofpress/dangerzone/issues/420))
- Platform support: Drop Ubuntu 22.10 (Kinetic Kudu), since it's end-of-life ([issue #485](https://github.com/freedomofpress/dangerzone/issues/485))
### Fixed
- Add missing language detection (OCR) models ([issue #357](https://github.com/freedomofpress/dangerzone/issues/357))
- Replace deprecated `pipes` module with `shlex` ([issue #373](https://github.com/freedomofpress/dangerzone/issues/373), thanks to [@OctopusET](https://github.com/OctopusET))
- Shrink container image with `--no-cache` option on `apk` ([issue #459](https://github.com/freedomofpress/dangerzone/issues/459), thanks to [@OctopusET](https://github.com/OctopusET))
### Security
- Continuously scan our Python dependencies and container image for
vulnerabilities ([issue #222](https://github.com/freedomofpress/dangerzone/issues/222))
- Sanitize potentially unsafe characters from strings that are shown in the
GUI/terminal ([PR #491](https://github.com/freedomofpress/dangerzone/pull/491))
## Dangerzone 0.4.1
### Added
- Feature: Add version info in the CLI and GUI ([issue #219](https://github.com/freedomofpress/dangerzone/issues/219))
- Development: Improve CI stability and coverage
([issue #292](https://github.com/freedomofpress/dangerzone/issues/292),
[issue #217](https://github.com/freedomofpress/dangerzone/issues/217),
[issue #229](https://github.com/freedomofpress/dangerzone/issues/229))
- Development: Provide dev scripts for testing Dangerzone in a container and
running our QA pipeline
([issue #286](https://github.com/freedomofpress/dangerzone/issues/286),
[issue #287](https://github.com/freedomofpress/dangerzone/issues/287))
- Development: Support Dangerzone development on Fedora 37
([issue #294](https://github.com/freedomofpress/dangerzone/issues/294))
- Development: Allow running Mypy on MacOS M1 machines ([issue #177](https://github.com/freedomofpress/dangerzone/issues/177))
- Development: Add dummy isolation provider for testing non-conversion-related
issues in virtualized Windows and MacOS, where Docker can't run, due to the
lack of nested virtualization ([issue #229](https://github.com/freedomofpress/dangerzone/issues/229))
- Add support for more MIME types that were previously disregarded ([issue #369](https://github.com/freedomofpress/dangerzone/issues/369))
- Platform support: Add support for Fedora 38
### Changed
- Full release under Freedom of the Press Foundation: signing keys have changed from the original developer Micah Lee / First Look Media to FPF's signing keys. Linux packages moved from Packagecloud to FPF's servers
- [Installation instructions updated](https://github.com/freedomofpress/dangerzone/blob/v0.4.1/INSTALL.md) to reflect change in key owership to FPF
- Platform support: MacOS (Apple Silicon) native application with significant
performance boost ([issue #50](https://github.com/freedomofpress/dangerzone/issues/50))
- Feature: Introduce PySide6 / Qt6 support on Windows, MacOS, and Linux (dev-only) ([issue #219](https://github.com/freedomofpress/dangerzone/issues/219))
- Feature: Adjust conversion timeouts based on the document's pages/size, and
allow users to disable them with `--disable-timeouts` (available when you run
the Dangerzone from the terminal) ([issue #327](https://github.com/freedomofpress/dangerzone/issues/327))
- Development: Update Linux instructions for development on Qubes
### Removed
- Platform support: Drop Fedora 35, since it's end-of-life ([issue #308](https://github.com/freedomofpress/dangerzone/issues/308))
- Bug fix: Remove unused PDFtk and sudo libraries from the container image, to
lower its attack surface and reduce its size ([issue #232](https://github.com/freedomofpress/dangerzone/issues/232))
### Fixed
- Feature: Convert documents with non-standard permissions or SELinux labels ([issue #335](https://github.com/freedomofpress/dangerzone/issues/335))
- Bug fix: Report exceptions during conversions ([issue #309](https://github.com/freedomofpress/dangerzone/issues/309))
- Bug fix: (Windows) Fix Dangerzone description on "Open With" ([issue #283](https://github.com/freedomofpress/dangerzone/issues/283))
- Bug fix: Remove document conversion artifacts when conversion fails and store
them on volatile memory instead of on a disk directory
([issue #317](https://github.com/freedomofpress/dangerzone/issues/317))
### Security
- Bug fix: Do not print debug logs in end-user executables ([issue #316](https://github.com/freedomofpress/dangerzone/issues/316))
## Dangerzone 0.4.0
- Platform support: Re-add Fedora 37 support
- Platform support: Add Debian Bookworm (12) support ([issue #172](https://github.com/freedomofpress/dangerzone/issues/172))
- Platform support: Reinstate Ubuntu Focal support ([issue #206](https://github.com/freedomofpress/dangerzone/issues/206))
- Platform support: Add Ubuntu 22.10 "Kinetic Kudu" support ([issue #265](https://github.com/freedomofpress/dangerzone/issues/265))
- Feature: Support bulk conversion to safe PDFs ([issue #77](https://github.com/freedomofpress/dangerzone/issues/77))
- Feature: Option to archive unsafe directories ([issue #255](https://github.com/freedomofpress/dangerzone/pull/255))
- Feature: Support python 3.10
- Feature: When quitting while still converting, confirm if user is sure
- Bug fix: Fix unit tests on Windows
- Bug fix: Do not hardcode "docker" in help messages, now that Podman is also used ([issue #122](https://github.com/freedomofpress/dangerzone/issues/122))
- Bug fix: Failed execution no longer produces an empty "safe" documents ([issue #214](https://github.com/freedomofpress/dangerzone/issues/214))
- Bug fix: Malfunctioning "New window" logic was replaced with multi-doc support ([issue #204](https://github.com/freedomofpress/dangerzone/issues/204))
- Bug fix: re-adds support for 'open with Dangerzone' from finder on macOS ([issue #268](https://github.com/freedomofpress/dangerzone/issues/268))
- Bug fix: (macOS) quit Dangerzone when main window is closed ([issue #271](https://github.com/freedomofpress/dangerzone/issues/271))
## Dangerzone 0.3.2
- Bug fix: some non-ascii characters like “ would prevent Dangerzone from working ([issue #144](https://github.com/freedomofpress/dangerzone/issues/144))
- Bug fix: error where Dangerzone would show "permission denied: '/tmp/input_file'" ([issue #157](https://github.com/freedomofpress/dangerzone/issues/157))
- Bug fix: remove containers after use, enabling Dangerzone to run after 1000+ converted docs ([issue #197](https://github.com/freedomofpress/dangerzone/pull/197))
- Security: limit container capabilities, run in container as non-root and limit privilege escalation ([issue #169](https://github.com/freedomofpress/dangerzone/issues/169))
## Dangerzone 0.3.1
- Bug fix: Allow converting documents on different mounted filesystems than the container volume
- Bug fix: In GUI mode, don't always OCR document
- Bug fix: In macOS, fix "open with" Dangerzone so documents are automatically selected
- Windows: Change packaging to avoid anti-virus false positives
## Dangerzone 0.3 ## Dangerzone 0.3
- Removes the need for internet access by shipping the Dangerzone container image directly with the software - Removes the need for internet access by shipping the Dangerzone container image directly with the software
- Friendly user experience with a progress bar - Friendly user experience with a progress bar
- Support for Macs with M1 chips
## Dangerzone 0.2.1 ## Dangerzone 0.2.1
@ -474,4 +50,4 @@ since 0.4.1, and this project adheres to [Semantic Versioning](https://semver.or
## Dangerzone 0.1 ## Dangerzone 0.1
- First release - First release

View file

@ -1,228 +0,0 @@
# NOTE: Updating the packages to their latest versions requires bumping the
# Dockerfile args below. For more info about this file, read
# docs/developer/reproducibility.md.
ARG DEBIAN_IMAGE_DIGEST=sha256:1209d8fd77def86ceb6663deef7956481cc6c14a25e1e64daec12c0ceffcc19d
FROM docker.io/library/debian@${DEBIAN_IMAGE_DIGEST} AS dangerzone-image
ARG GVISOR_ARCHIVE_DATE=20250326
ARG DEBIAN_ARCHIVE_DATE=20250331
ARG H2ORESTART_CHECKSUM=935e68671bde4ca63a364128077f1c733349bbcc90b7e6973bc7a2306494ec54
ARG H2ORESTART_VERSION=v0.7.2
ENV DEBIAN_FRONTEND=noninteractive
# The following way of installing packages is taken from
# https://github.com/reproducible-containers/repro-sources-list.sh/blob/master/Dockerfile.debian-12,
# and adapted to allow installing gVisor from each own repo as well.
RUN \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
--mount=type=bind,source=./container_helpers/repro-sources-list.sh,target=/usr/local/bin/repro-sources-list.sh \
--mount=type=bind,source=./container_helpers/gvisor.key,target=/tmp/gvisor.key \
: "Hacky way to set a date for the Debian snapshot repos" && \
touch -d ${DEBIAN_ARCHIVE_DATE}Z /etc/apt/sources.list.d/debian.sources && \
touch -d ${DEBIAN_ARCHIVE_DATE}Z /etc/apt/sources.list && \
repro-sources-list.sh && \
: "Setup APT to install gVisor from its separate APT repo" && \
apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends apt-transport-https ca-certificates gnupg && \
gpg -o /usr/share/keyrings/gvisor-archive-keyring.gpg --dearmor /tmp/gvisor.key && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases ${GVISOR_ARCHIVE_DATE} main" > /etc/apt/sources.list.d/gvisor.list && \
: "Install the necessary gVisor and Dangerzone dependencies" && \
apt-get update && \
apt-get install -y --no-install-recommends \
python3 python3-fitz libreoffice-nogui libreoffice-java-common \
python3 python3-magic default-jre-headless fonts-noto-cjk fonts-dejavu \
runsc unzip wget && \
: "Clean up for improving reproducibility (optional)" && \
rm -rf /var/cache/fontconfig/ && \
rm -rf /etc/ssl/certs/java/cacerts && \
rm -rf /var/log/* /var/cache/ldconfig/aux-cache
# Download H2ORestart from GitHub using a pinned version and hash. Note that
# it's available in Debian repos, but not in Bookworm yet.
RUN mkdir /opt/libreoffice_ext && cd /opt/libreoffice_ext \
&& H2ORESTART_FILENAME=h2orestart.oxt \
&& wget https://github.com/ebandal/H2Orestart/releases/download/$H2ORESTART_VERSION/$H2ORESTART_FILENAME \
&& echo "$H2ORESTART_CHECKSUM $H2ORESTART_FILENAME" | sha256sum -c \
&& install -dm777 "/usr/lib/libreoffice/share/extensions/" \
&& rm /root/.wget-hsts
# Create an unprivileged user both for gVisor and for running Dangerzone.
# XXX: Make the shadow field "date of last password change" a constant
# number.
RUN addgroup --gid 1000 dangerzone
RUN adduser --uid 1000 --ingroup dangerzone --shell /bin/true \
--disabled-password --home /home/dangerzone dangerzone \
&& chage -d 99999 dangerzone \
&& rm /etc/shadow-
# Copy Dangerzone's conversion logic under /opt/dangerzone, and allow Python to
# import it.
RUN mkdir -p /opt/dangerzone/dangerzone
RUN touch /opt/dangerzone/dangerzone/__init__.py
# Copy only the Python code, and not any produced .pyc files.
COPY conversion/*.py /opt/dangerzone/dangerzone/conversion/
# Create a directory that will be used by gVisor as the place where it will
# store the state of its containers.
RUN mkdir /home/dangerzone/.containers
###############################################################################
#
# REUSING CONTAINER IMAGES:
# Anatomy of a hack
# ========================
#
# The rest of the Dockerfile aims to do one thing: allow the final container
# image to actually contain two container images; one for the outer container
# (spawned by Podman/Docker Desktop), and one for the inner container (spawned
# by gVisor).
#
# This has already been done in the past, and we explain why and how in the
# design document for gVisor integration (should be in
# `docs/developer/gvisor.md`). In this iteration, we want to also
# achieve the following:
#
# 1. Have a small final image, by sharing some system paths between the inner
# and outer container image using symlinks.
# 2. Allow our security scanning tool to see the contents of the inner
# container image.
# 3. Make the outer container image operational, in the sense that you can use
# `apt` commands and perform a conversion with Dangerzone, outside the
# gVisor sandbox. This is helpful for debugging purposes.
#
# Below we'll explain how our design choices are informed by the above
# sub-goals.
#
# First, to achieve a small container image, we basically need to copy `/etc`,
# `/usr` and `/opt` from the original Dangerzone image to the **inner**
# container image (under `/home/dangerzone/dangerzone-image/rootfs/`)
#
# That's all we need. The rest of the files play no role, and we can actually
# mask them in gVisor's OCI config.
#
# Second, in order to let our security scanner find the installed packages,
# we need to copy the following dirs to the root of the **outer** container
# image:
# * `/etc`, so that the security scanner can detect the image type and its
# sources
# * `/var`, so that the security scanner can have access to the APT database.
#
# IMPORTANT: We don't symlink the `/etc` of the **outer** container image to
# the **inner** one, in order to avoid leaking files like
# `/etc/{hostname,hosts,resolv.conf}` that Podman/Docker mounts when running
# the **outer** container image.
#
# Third, in order to have an operational Debian image, we are _mostly_ covered
# by the dirs we have copied. There's a _rare_ case where during debugging, we
# may want to install a system package that has components in `/etc` and
# `/var`, which will not be available in the **inner** container image. In that
# case, the developer can do the necessary symlinks in the live container.
#
# FILESYSTEM HIERARCHY
# ====================
#
# The above plan leads to the following filesystem hierarchy:
#
# Outer container image:
#
# # ls -l /
# lrwxrwxrwx 1 root root 7 Jan 27 10:46 bin -> usr/bin
# -rwxr-xr-x 1 root root 7764 Jan 24 08:14 entrypoint.py
# drwxr-xr-x 1 root root 4096 Jan 27 10:47 etc
# drwxr-xr-x 1 root root 4096 Jan 27 10:46 home
# lrwxrwxrwx 1 root root 7 Jan 27 10:46 lib -> usr/lib
# lrwxrwxrwx 1 root root 9 Jan 27 10:46 lib64 -> usr/lib64
# drwxr-xr-x 2 root root 4096 Jan 27 10:46 root
# drwxr-xr-x 1 root root 4096 Jan 27 10:47 run
# lrwxrwxrwx 1 root root 8 Jan 27 10:46 sbin -> usr/sbin
# drwxrwxrwx 2 root root 4096 Jan 27 10:46 tmp
# lrwxrwxrwx 1 root root 44 Jan 27 10:46 usr -> /home/dangerzone/dangerzone-image/rootfs/usr
# drwxr-xr-x 11 root root 4096 Jan 27 10:47 var
#
# Inner container image:
#
# # ls -l /home/dangerzone/dangerzone-image/rootfs/
# total 12
# lrwxrwxrwx 1 root root 7 Jan 27 10:47 bin -> usr/bin
# drwxr-xr-x 43 root root 4096 Jan 27 10:46 etc
# lrwxrwxrwx 1 root root 7 Jan 27 10:47 lib -> usr/lib
# lrwxrwxrwx 1 root root 9 Jan 27 10:47 lib64 -> usr/lib64
# drwxr-xr-x 4 root root 4096 Jan 27 10:47 opt
# drwxr-xr-x 12 root root 4096 Jan 27 10:47 usr
#
# SYMLINKING /USR
# ===============
#
# It's surprisingly difficult (maybe even borderline impossible), to symlink
# `/usr` to a different path during image build. The problem is that /usr
# is very sensitive, and you can't manipulate it in a live system. That is, I
# haven't found a way to do the following, or something equivalent:
#
# rm -r /usr && ln -s /home/dangerzone/dangerzone-image/rootfs/usr/ /usr
#
# The `ln` binary, even if you specify it by its full path, cannot run
# (probably because `ld-linux.so` can't be found). For this reason, we have
# to create the symlinks beforehand, in a previous build stage. Then, in an
# empty container image (scratch images), we can copy these symlinks and the
# /usr, and stitch everything together.
###############################################################################
# Create the filesystem hierarchy that will be used to symlink /usr.
RUN mkdir -p \
/new_root \
/new_root/root \
/new_root/run \
/new_root/tmp \
/new_root/home/dangerzone/dangerzone-image/rootfs
# Copy the /etc and /var directories under the new root directory. Also,
# copy /etc/, /opt, and /usr to the Dangerzone image rootfs.
#
# NOTE: We also have to remove the resolv.conf file, in order to not leak any
# DNS servers added there during image build time.
RUN cp -r /etc /var /new_root/ \
&& rm /new_root/etc/resolv.conf
RUN cp -r /etc /opt /usr /new_root/home/dangerzone/dangerzone-image/rootfs \
&& rm /new_root/home/dangerzone/dangerzone-image/rootfs/etc/resolv.conf
RUN ln -s /home/dangerzone/dangerzone-image/rootfs/usr /new_root/usr
RUN ln -s usr/bin /new_root/bin
RUN ln -s usr/lib /new_root/lib
RUN ln -s usr/lib64 /new_root/lib64
RUN ln -s usr/sbin /new_root/sbin
RUN ln -s usr/bin /new_root/home/dangerzone/dangerzone-image/rootfs/bin
RUN ln -s usr/lib /new_root/home/dangerzone/dangerzone-image/rootfs/lib
RUN ln -s usr/lib64 /new_root/home/dangerzone/dangerzone-image/rootfs/lib64
# Fix permissions in /home/dangerzone, so that our entrypoint script can make
# changes in the following folders.
RUN chown dangerzone:dangerzone \
/new_root/home/dangerzone \
/new_root/home/dangerzone/dangerzone-image/
# Fix permissions in /tmp, so that it can be used by unprivileged users.
RUN chmod 777 /new_root/tmp
COPY container_helpers/entrypoint.py /new_root
# HACK: For reasons that we are not sure yet, we need to explicitly specify the
# modification time of this file.
RUN touch -d ${DEBIAN_ARCHIVE_DATE}Z /new_root/entrypoint.py
## Final image
FROM scratch
# Copy the filesystem hierarchy that we created in the previous stage, so that
# /usr can be a symlink.
COPY --from=dangerzone-image /new_root/ /
# Switch to the dangerzone user for the rest of the script.
USER dangerzone
ENTRYPOINT ["/entrypoint.py"]

View file

@ -1,16 +0,0 @@
# Should be the INDEX DIGEST from an image tagged `bookworm-<DATE>-slim`:
# https://hub.docker.com/_/debian/tags?name=bookworm-
#
# Tag for this digest: bookworm-20250317-slim
DEBIAN_IMAGE_DIGEST=sha256:1209d8fd77def86ceb6663deef7956481cc6c14a25e1e64daec12c0ceffcc19d
# Can be bumped to today's date
DEBIAN_ARCHIVE_DATE=20250331
# Can be bumped to the latest date in https://github.com/google/gvisor/tags
GVISOR_ARCHIVE_DATE=20250326
# Can be bumped to the latest version and checksum from https://github.com/ebandal/H2Orestart/releases
H2ORESTART_CHECKSUM=935e68671bde4ca63a364128077f1c733349bbcc90b7e6973bc7a2306494ec54
H2ORESTART_VERSION=v0.7.2
# Buildkit image (taken from freedomofpress/repro-build)
BUILDKIT_IMAGE="docker.io/moby/buildkit:v19.0@sha256:14aa1b4dd92ea0a4cd03a54d0c6079046ea98cd0c0ae6176bdd7036ba370cbbe"
BUILDKIT_IMAGE_ROOTLESS="docker.io/moby/buildkit:v0.19.0-rootless@sha256:e901cffdad753892a7c3afb8b9972549fca02c73888cf340c91ed801fdd96d71"

View file

@ -1,228 +0,0 @@
# NOTE: Updating the packages to their latest versions requires bumping the
# Dockerfile args below. For more info about this file, read
# docs/developer/reproducibility.md.
ARG DEBIAN_IMAGE_DIGEST={{DEBIAN_IMAGE_DIGEST}}
FROM docker.io/library/debian@${DEBIAN_IMAGE_DIGEST} AS dangerzone-image
ARG GVISOR_ARCHIVE_DATE={{GVISOR_ARCHIVE_DATE}}
ARG DEBIAN_ARCHIVE_DATE={{DEBIAN_ARCHIVE_DATE}}
ARG H2ORESTART_CHECKSUM={{H2ORESTART_CHECKSUM}}
ARG H2ORESTART_VERSION={{H2ORESTART_VERSION}}
ENV DEBIAN_FRONTEND=noninteractive
# The following way of installing packages is taken from
# https://github.com/reproducible-containers/repro-sources-list.sh/blob/master/Dockerfile.debian-12,
# and adapted to allow installing gVisor from each own repo as well.
RUN \
--mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
--mount=type=bind,source=./container_helpers/repro-sources-list.sh,target=/usr/local/bin/repro-sources-list.sh \
--mount=type=bind,source=./container_helpers/gvisor.key,target=/tmp/gvisor.key \
: "Hacky way to set a date for the Debian snapshot repos" && \
touch -d ${DEBIAN_ARCHIVE_DATE}Z /etc/apt/sources.list.d/debian.sources && \
touch -d ${DEBIAN_ARCHIVE_DATE}Z /etc/apt/sources.list && \
repro-sources-list.sh && \
: "Setup APT to install gVisor from its separate APT repo" && \
apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends apt-transport-https ca-certificates gnupg && \
gpg -o /usr/share/keyrings/gvisor-archive-keyring.gpg --dearmor /tmp/gvisor.key && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases ${GVISOR_ARCHIVE_DATE} main" > /etc/apt/sources.list.d/gvisor.list && \
: "Install the necessary gVisor and Dangerzone dependencies" && \
apt-get update && \
apt-get install -y --no-install-recommends \
python3 python3-fitz libreoffice-nogui libreoffice-java-common \
python3 python3-magic default-jre-headless fonts-noto-cjk fonts-dejavu \
runsc unzip wget && \
: "Clean up for improving reproducibility (optional)" && \
rm -rf /var/cache/fontconfig/ && \
rm -rf /etc/ssl/certs/java/cacerts && \
rm -rf /var/log/* /var/cache/ldconfig/aux-cache
# Download H2ORestart from GitHub using a pinned version and hash. Note that
# it's available in Debian repos, but not in Bookworm yet.
RUN mkdir /opt/libreoffice_ext && cd /opt/libreoffice_ext \
&& H2ORESTART_FILENAME=h2orestart.oxt \
&& wget https://github.com/ebandal/H2Orestart/releases/download/$H2ORESTART_VERSION/$H2ORESTART_FILENAME \
&& echo "$H2ORESTART_CHECKSUM $H2ORESTART_FILENAME" | sha256sum -c \
&& install -dm777 "/usr/lib/libreoffice/share/extensions/" \
&& rm /root/.wget-hsts
# Create an unprivileged user both for gVisor and for running Dangerzone.
# XXX: Make the shadow field "date of last password change" a constant
# number.
RUN addgroup --gid 1000 dangerzone
RUN adduser --uid 1000 --ingroup dangerzone --shell /bin/true \
--disabled-password --home /home/dangerzone dangerzone \
&& chage -d 99999 dangerzone \
&& rm /etc/shadow-
# Copy Dangerzone's conversion logic under /opt/dangerzone, and allow Python to
# import it.
RUN mkdir -p /opt/dangerzone/dangerzone
RUN touch /opt/dangerzone/dangerzone/__init__.py
# Copy only the Python code, and not any produced .pyc files.
COPY conversion/*.py /opt/dangerzone/dangerzone/conversion/
# Create a directory that will be used by gVisor as the place where it will
# store the state of its containers.
RUN mkdir /home/dangerzone/.containers
###############################################################################
#
# REUSING CONTAINER IMAGES:
# Anatomy of a hack
# ========================
#
# The rest of the Dockerfile aims to do one thing: allow the final container
# image to actually contain two container images; one for the outer container
# (spawned by Podman/Docker Desktop), and one for the inner container (spawned
# by gVisor).
#
# This has already been done in the past, and we explain why and how in the
# design document for gVisor integration (should be in
# `docs/developer/gvisor.md`). In this iteration, we want to also
# achieve the following:
#
# 1. Have a small final image, by sharing some system paths between the inner
# and outer container image using symlinks.
# 2. Allow our security scanning tool to see the contents of the inner
# container image.
# 3. Make the outer container image operational, in the sense that you can use
# `apt` commands and perform a conversion with Dangerzone, outside the
# gVisor sandbox. This is helpful for debugging purposes.
#
# Below we'll explain how our design choices are informed by the above
# sub-goals.
#
# First, to achieve a small container image, we basically need to copy `/etc`,
# `/usr` and `/opt` from the original Dangerzone image to the **inner**
# container image (under `/home/dangerzone/dangerzone-image/rootfs/`)
#
# That's all we need. The rest of the files play no role, and we can actually
# mask them in gVisor's OCI config.
#
# Second, in order to let our security scanner find the installed packages,
# we need to copy the following dirs to the root of the **outer** container
# image:
# * `/etc`, so that the security scanner can detect the image type and its
# sources
# * `/var`, so that the security scanner can have access to the APT database.
#
# IMPORTANT: We don't symlink the `/etc` of the **outer** container image to
# the **inner** one, in order to avoid leaking files like
# `/etc/{hostname,hosts,resolv.conf}` that Podman/Docker mounts when running
# the **outer** container image.
#
# Third, in order to have an operational Debian image, we are _mostly_ covered
# by the dirs we have copied. There's a _rare_ case where during debugging, we
# may want to install a system package that has components in `/etc` and
# `/var`, which will not be available in the **inner** container image. In that
# case, the developer can do the necessary symlinks in the live container.
#
# FILESYSTEM HIERARCHY
# ====================
#
# The above plan leads to the following filesystem hierarchy:
#
# Outer container image:
#
# # ls -l /
# lrwxrwxrwx 1 root root 7 Jan 27 10:46 bin -> usr/bin
# -rwxr-xr-x 1 root root 7764 Jan 24 08:14 entrypoint.py
# drwxr-xr-x 1 root root 4096 Jan 27 10:47 etc
# drwxr-xr-x 1 root root 4096 Jan 27 10:46 home
# lrwxrwxrwx 1 root root 7 Jan 27 10:46 lib -> usr/lib
# lrwxrwxrwx 1 root root 9 Jan 27 10:46 lib64 -> usr/lib64
# drwxr-xr-x 2 root root 4096 Jan 27 10:46 root
# drwxr-xr-x 1 root root 4096 Jan 27 10:47 run
# lrwxrwxrwx 1 root root 8 Jan 27 10:46 sbin -> usr/sbin
# drwxrwxrwx 2 root root 4096 Jan 27 10:46 tmp
# lrwxrwxrwx 1 root root 44 Jan 27 10:46 usr -> /home/dangerzone/dangerzone-image/rootfs/usr
# drwxr-xr-x 11 root root 4096 Jan 27 10:47 var
#
# Inner container image:
#
# # ls -l /home/dangerzone/dangerzone-image/rootfs/
# total 12
# lrwxrwxrwx 1 root root 7 Jan 27 10:47 bin -> usr/bin
# drwxr-xr-x 43 root root 4096 Jan 27 10:46 etc
# lrwxrwxrwx 1 root root 7 Jan 27 10:47 lib -> usr/lib
# lrwxrwxrwx 1 root root 9 Jan 27 10:47 lib64 -> usr/lib64
# drwxr-xr-x 4 root root 4096 Jan 27 10:47 opt
# drwxr-xr-x 12 root root 4096 Jan 27 10:47 usr
#
# SYMLINKING /USR
# ===============
#
# It's surprisingly difficult (maybe even borderline impossible), to symlink
# `/usr` to a different path during image build. The problem is that /usr
# is very sensitive, and you can't manipulate it in a live system. That is, I
# haven't found a way to do the following, or something equivalent:
#
# rm -r /usr && ln -s /home/dangerzone/dangerzone-image/rootfs/usr/ /usr
#
# The `ln` binary, even if you specify it by its full path, cannot run
# (probably because `ld-linux.so` can't be found). For this reason, we have
# to create the symlinks beforehand, in a previous build stage. Then, in an
# empty container image (scratch images), we can copy these symlinks and the
# /usr, and stitch everything together.
###############################################################################
# Create the filesystem hierarchy that will be used to symlink /usr.
RUN mkdir -p \
/new_root \
/new_root/root \
/new_root/run \
/new_root/tmp \
/new_root/home/dangerzone/dangerzone-image/rootfs
# Copy the /etc and /var directories under the new root directory. Also,
# copy /etc/, /opt, and /usr to the Dangerzone image rootfs.
#
# NOTE: We also have to remove the resolv.conf file, in order to not leak any
# DNS servers added there during image build time.
RUN cp -r /etc /var /new_root/ \
&& rm /new_root/etc/resolv.conf
RUN cp -r /etc /opt /usr /new_root/home/dangerzone/dangerzone-image/rootfs \
&& rm /new_root/home/dangerzone/dangerzone-image/rootfs/etc/resolv.conf
RUN ln -s /home/dangerzone/dangerzone-image/rootfs/usr /new_root/usr
RUN ln -s usr/bin /new_root/bin
RUN ln -s usr/lib /new_root/lib
RUN ln -s usr/lib64 /new_root/lib64
RUN ln -s usr/sbin /new_root/sbin
RUN ln -s usr/bin /new_root/home/dangerzone/dangerzone-image/rootfs/bin
RUN ln -s usr/lib /new_root/home/dangerzone/dangerzone-image/rootfs/lib
RUN ln -s usr/lib64 /new_root/home/dangerzone/dangerzone-image/rootfs/lib64
# Fix permissions in /home/dangerzone, so that our entrypoint script can make
# changes in the following folders.
RUN chown dangerzone:dangerzone \
/new_root/home/dangerzone \
/new_root/home/dangerzone/dangerzone-image/
# Fix permissions in /tmp, so that it can be used by unprivileged users.
RUN chmod 777 /new_root/tmp
COPY container_helpers/entrypoint.py /new_root
# HACK: For reasons that we are not sure yet, we need to explicitly specify the
# modification time of this file.
RUN touch -d ${DEBIAN_ARCHIVE_DATE}Z /new_root/entrypoint.py
## Final image
FROM scratch
# Copy the filesystem hierarchy that we created in the previous stage, so that
# /usr can be a symlink.
COPY --from=dangerzone-image /new_root/ /
# Switch to the dangerzone user for the rest of the script.
USER dangerzone
ENTRYPOINT ["/entrypoint.py"]

View file

@ -1,394 +0,0 @@
## Operating System support
Dangerzone can run on various Operating Systems (OS), and has automated tests
for most of them.
This section explains which OS we support, how long we support each version, and
how do we test Dangerzone against these.
You can find general support information in this table, and more details in the
following sections.
(Unless specified, the architecture of the OS is AMD64)
| Distribution | Supported releases | Automated tests | Manual QA |
| ------------ | ------------------------- | ---------------------- | --------- |
| Windows | 2 last releases | 🗹 (`windows-latest`) ◎ | 🗹 |
| macOS intel | 3 last releases | 🗹 (`macos-13`) ◎ | 🗹 |
| macOS silicon | 3 last releases | 🗹 (`macos-latest`) ◎ | 🗹 |
| Ubuntu | Follow upstream support ✰ | 🗹 | 🗹 |
| Debian | Current stable, Oldstable and LTS releases | 🗹 | 🗹 |
| Fedora | Follow upstream support | 🗹 | 🗹 |
| Qubes OS | [Beta support](https://github.com/freedomofpress/dangerzone/issues/413) ✢ | 🗷 | Latest Fedora template |
| Tails | Only the last release | 🗷 | Last release only |
Notes:
✰ Support for Ubuntu Focal [was dropped](https://github.com/freedomofpress/dangerzone/issues/1018)
✢ Qubes OS support assumes the use of a Fedora template. The supported releases follow our general support for Fedora.
◎ More information about where that points [in the runner-images repository](https://github.com/actions/runner-images/tree/main)
## MacOS
- Download [Dangerzone 0.9.0 for Mac (Apple Silicon CPU)](https://github.com/freedomofpress/dangerzone/releases/download/v0.9.0/Dangerzone-0.9.0-arm64.dmg)
- Download [Dangerzone 0.9.0 for Mac (Intel CPU)](https://github.com/freedomofpress/dangerzone/releases/download/v0.9.0/Dangerzone-0.9.0-i686.dmg)
> [!TIP]
> We support the releases of macOS that are still within Apple's servicing timeline. Apple usually provides security updates for the latest 3 releases, but this isnt consistently applied and security fixes arent guaranteed for the non-latest releases. We are also dependent on [Docker Desktop windows support](https://docs.docker.com/desktop/setup/install/mac-install/)
You can also install Dangerzone for Mac using [Homebrew](https://brew.sh/): `brew install --cask dangerzone`
> **Note**: you will also need to install [Docker Desktop](https://www.docker.com/products/docker-desktop/).
> This program needs to run alongside Dangerzone at all times, since it is what allows Dangerzone to
> create the secure environment.
## Windows
- Download [Dangerzone 0.9.0 for Windows](https://github.com/freedomofpress/dangerzone/releases/download/v0.9.0/Dangerzone-0.9.0.msi)
> **Note**: you will also need to install [Docker Desktop](https://www.docker.com/products/docker-desktop/).
> This program needs to run alongside Dangerzone at all times, since it is what allows Dangerzone to
> create the secure environment.
> [!TIP]
> We generally support Windows releases that are still within [Microsofts servicing timeline](https://support.microsoft.com/en-us/help/13853/windows-lifecycle-fact-sheet).
>
> Docker sets the bottom line:
>
> > Docker only supports Docker Desktop on Windows for those versions of Windows that are still within [Microsofts servicing timeline](https://support.microsoft.com/en-us/help/13853/windows-lifecycle-fact-sheet). Docker Desktop is not supported on server versions of Windows, such as Windows Server 2019 or Windows Server 2022.
## Linux
On Linux, Dangerzone uses [Podman](https://podman.io/) instead of Docker Desktop for creating
an isolated environment. It will be installed automatically when installing Dangerzone.
> [!TIP]
> We support Ubuntu, Debian, and Fedora releases that are still within
> their respective servicing timelines, with a few twists:
>
> - Ubuntu: We follow upstream support with an extra cutoff date. No support for
> versions prior to the second oldest LTS release.
> - Fedora: We follow upstream support
> - Debian: current stable, oldstable and LTS releases.
Dangerzone is available for:
- Ubuntu 25.04 (plucky)
- Ubuntu 24.10 (oracular)
- Ubuntu 24.04 (noble)
- Ubuntu 22.04 (jammy)
- Debian 13 (trixie)
- Debian 12 (bookworm)
- Debian 11 (bullseye)
- Fedora 42
- Fedora 41
- Fedora 40
- Tails
- Qubes OS (beta support)
### Ubuntu, Debian
<table>
<tr>
<td>
<details>
<summary><i>:information_source: Backport notice for Ubuntu 22.04 (Jammy) users regarding the <code>conmon</code> package</i></summary>
</br>
The `conmon` version that Podman uses and Ubuntu Jammy ships, has a bug
that gets triggered by Dangerzone
(more details in https://github.com/freedomofpress/dangerzone/issues/685).
To fix this, we provide our own `conmon` package through our APT repo, which
was built with the following [instructions](https://github.com/freedomofpress/maint-dangerzone-conmon/tree/ubuntu/jammy/fpf).
This package is essentially a backport of the `conmon` package
[provided](https://packages.debian.org/source/oldstable/conmon) by Debian
Bullseye.
</details>
</td>
</tr>
</table>
First, retrieve the PGP keys. The instructions differ depending on the specific
distribution you are using:
For Debian Trixie and Ubuntu Plucky (25.04), follow these instructions to
download the PGP keys:
```bash
sudo apt-get update && sudo apt-get install sq ca-certificates -y
sq network keyserver \
--server hkps://keys.openpgp.org \
search "DE28 AB24 1FA4 8260 FAC9 B8BA A7C9 B385 2260 4281" \
--output - | sq packet dearmor fpfdz.gpg
sudo mkdir -p /etc/apt/keyrings/
sudo mv fpfdz.gpg /etc/apt/keyrings/fpf-apt-tools-archive-keyring.gpg
```
On other Debian-derivatives:
```sh
sudo apt-get update && sudo apt-get install gnupg2 ca-certificates -y
sudo mkdir -p /etc/apt/keyrings/
sudo gpg --keyserver hkps://keys.openpgp.org \
--no-default-keyring --keyring /etc/apt/keyrings/fpf-apt-tools-archive-keyring.gpg \
--recv-keys "DE28 AB24 1FA4 8260 FAC9 B8BA A7C9 B385 2260 4281"
```
Then, on all distributions, add the URL of the repo in your APT sources:
```sh
. /etc/os-release
echo "deb [signed-by=/etc/apt/keyrings/fpf-apt-tools-archive-keyring.gpg] \
https://packages.freedom.press/apt-tools-prod ${VERSION_CODENAME?} main" \
| sudo tee /etc/apt/sources.list.d/fpf-apt-tools.list
```
Install Dangerzone:
```
sudo apt update
sudo apt install -y dangerzone
```
<table>
<tr>
<td>
<details>
<summary><i>:memo: Expand this section for a security notice on third-party Debian repos</i></summary>
</br>
This section follows the official instructions on configuring [third-party
Debian repos](https://wiki.debian.org/DebianRepository/UseThirdParty).
To mitigate a class of attacks against our APT repo (e.g., injecting packages
signed with an attacker key), we add an additional step in our instructions to
verify the downloaded GPG key against its fingerprint.
Aside from these protections, the user needs to be aware that Debian packages
run as `root` during the installation phase, so they need to place some trust
on our signed Debian packages. This holds for any third-party Debian repo.
</details>
</td>
</tr>
</table>
### Fedora
Type the following commands in a terminal:
```
sudo dnf install 'dnf-command(config-manager)'
sudo dnf-3 config-manager --add-repo=https://packages.freedom.press/yum-tools-prod/dangerzone/dangerzone.repo
sudo dnf install dangerzone
```
##### Verifying Dangerzone GPG key
<table>
<tr>
<td>
<details>
<summary>Importing GPG key 0x22604281: ... Is this ok [y/N]:</summary>
</br>
After some minutes of running the above command (depending on your internet speed) you'll be asked to confirm the fingerprint of our signing key. This is to make sure that in the case our servers are compromised your computer stays safe. It should look like this:
```console
--------------------------------------------------------------------------------
Total 389 kB/s | 732 MB 32:07
Dangerzone repository 3.8 MB/s | 3.8 kB 00:00
Importing GPG key 0x22604281:
Userid : "Dangerzone Release Key <dangerzone-release-key@freedom.press>"
Fingerprint: DE28 AB24 1FA4 8260 FAC9 B8BA A7C9 B385 2260 4281
From : /etc/pki/rpm-gpg/RPM-GPG-dangerzone.pub
Is this ok [y/N]:
```
> **Note**: If it does not show this fingerprint confirmation or the fingerprint does not match, it is possible that our servers were compromised. Be distrustful and reach out to us.
The `Fingerprint` should be `DE28 AB24 1FA4 8260 FAC9 B8BA A7C9 B385 2260 4281`. For extra security, you should confirm it matches the one at the bottom of our website ([dangerzone.rocks](https://dangerzone.rocks)) and our [Mastodon account](https://fosstodon.org/@dangerzone) bio.
After confirming that it matches, type `y` (for yes) and the installation should proceed.
</details>
</td>
</tr>
</table>
### Qubes OS
> [!WARNING]
> This section is for the beta version of native Qubes support. If you
> want to try out the stable Dangerzone version (which uses containers instead
> of virtual machines for isolation), please follow the Fedora or Debian
> instructions and adapt them as needed.
>
> **If you followed these instructions before October 25, 2023, please read [this security advisory](docs/advisories/2023-10-25.md).**
> This notice will be removed with the 1.0.0 release of Dangerzone.
> [!IMPORTANT]
> This section will install Dangerzone in your **default template**
> (`fedora-41` as of writing this). If you want to install it in a different
> one, make sure to replace `fedora-41` with the template of your choice.
The following steps must be completed once. Make sure you run them in the
specified qubes.
Overview of the qubes you'll create:
| qube | type | purpose |
|--------------|----------|---------|
| dz-dvm | app qube | offline disposable template for performing conversions |
#### In `dom0`:
Create a **disposable**, offline app qube (`dz-dvm`), based on your default
template. This will be the qube where the documents will be sanitized:
```
qvm-create --class AppVM --label red --template fedora-41 \
--prop netvm="" --prop template_for_dispvms=True \
--prop default_dispvm='' dz-dvm
```
Add an RPC policy (`/etc/qubes/policy.d/50-dangerzone.policy`) that will
allow launching a disposable qube (`dz-dvm`) when Dangerzone converts a
document, with the following contents:
```
dz.Convert * @anyvm @dispvm:dz-dvm allow
```
#### In the `fedora-41` template
Install Dangerzone:
```
sudo dnf-3 config-manager --add-repo=https://packages.freedom.press/yum-tools-prod/dangerzone/dangerzone.repo
sudo dnf install dangerzone-qubes
```
While Dangerzone gets installed, you will be prompted to accept a signing key.
Expand the instructions in the [Verifying Dangerzone GPG key](#verifying-dangerzone-gpg-key)
section to verify the key.
Finally, shutdown the template and restart the qubes where you want to use
Dangerzone in. Go to "Qube Settings" -> choose the "Applications" tab,
click on "Refresh applications", and then move "Dangerzone" from the "Available"
column to "Selected".
You can now launch Dangerzone from the list of applications for your qube, and
pass it a file to sanitize.
## Tails
Dangerzone is not yet available by default in Tails, but we have collaborated
with the Tails team to offer manual
[installation instructions](https://tails.net/doc/persistent_storage/additional_software/dangerzone/index.en.html)
for Tails users.
## Build from source
If you'd like to build from source, follow the [build instructions](BUILD.md).
## Verifying PGP signatures
You can verify that the package you download is legitimate and hasn't been
tampered with by verifying its PGP signature. For Windows and macOS, this step
is optional and provides defense in depth: the Dangerzone binaries include
operating system-specific signatures, and you can just rely on those alone if
you'd like.
### Obtaining signing key
Our binaries are signed with a PGP key owned by Freedom of the Press Foundation:
* Name: Dangerzone Release Key
* PGP public key fingerprint `DE28 AB24 1FA4 8260 FAC9 B8BA A7C9 B385 2260 4281`
- You can download this key [from the keys.openpgp.org keyserver](https://keys.openpgp.org/vks/v1/by-fingerprint/DE28AB241FA48260FAC9B8BAA7C9B38522604281).
_(You can also cross-check this fingerprint with the fingerprint in our
[Mastodon page](https://fosstodon.org/@dangerzone) and the fingerprint in the
footer of our [official site](https://dangerzone.rocks))_
You must have GnuPG installed to verify signatures. For macOS you probably want
[GPGTools](https://gpgtools.org/), and for Windows you probably want
[Gpg4win](https://www.gpg4win.org/).
### Signatures
Our [GitHub Releases page](https://github.com/freedomofpress/dangerzone/releases)
hosts the following files:
* Windows installer (`Dangerzone-<version>.msi`)
* macOS archives (`Dangerzone-<version>-<arch>.dmg`)
* Container images (`container-<version>-<arch>.tar`)
* Source package (`dangerzone-<version>.tar.gz`)
All these files are accompanied by signatures (as `.asc` files). We'll explain
how to verify them below, using `0.6.1` as an example.
### Verifying
Once you have imported the Dangerzone release key into your GnuPG keychain,
downloaded the binary and ``.asc`` signature, you can verify the binary in a
terminal like this:
For the Windows binary:
```
gpg --verify Dangerzone-0.6.1.msi.asc Dangerzone-0.6.1.msi
```
For the macOS binaries (depending on your architecture):
```
gpg --verify Dangerzone-0.6.1-arm64.dmg.asc Dangerzone-0.6.1-arm64.dmg
gpg --verify Dangerzone-0.6.1-i686.dmg.asc Dangerzone-0.6.1-i686.dmg
```
For the container images:
```
gpg --verify container-0.6.1-i686.tar.asc container-0.6.1-i686.tar
```
For the source package:
```
gpg --verify dangerzone-0.6.1.tar.gz.asc dangerzone-0.6.1.tar.gz
```
We also hash all the above files with SHA-256, and provide a list of these
hashes as a separate file (`checksums-0.6.1.txt`). This file is signed as well,
and the signature is embedded within it. You can download this file and verify
it with:
```
gpg --verify checksums.txt
```
The expected output looks like this:
```
gpg: Signature made Mon Apr 22 09:29:22 2024 PDT
gpg: using RSA key 04CABEB5DD76BACF2BD43D2FF3ACC60F62EA51CB
gpg: Good signature from "Dangerzone Release Key <dangerzone-release-key@freedom.press>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
Primary key fingerprint: DE28 AB24 1FA4 8260 FAC9 B8BA A7C9 B385 2260 4281
Subkey fingerprint: 04CA BEB5 DD76 BACF 2BD4 3D2F F3AC C60F 62EA 51CB
```
If you don't see `Good signature from`, there might be a problem with the
integrity of the file (malicious or otherwise), and you should not install the
package.
The `WARNING:` shown above, is not a problem with the package, it only means you
haven't defined a level of "trust" for Dangerzone's PGP key.
If you want to learn more about verifying PGP signatures, the guides for
[Qubes OS](https://www.qubes-os.org/security/verifying-signatures/) and the
[Tor Project](https://support.torproject.org/tbb/how-to-verify-signature/) may
be useful.

682
LICENSE
View file

@ -1,661 +1,21 @@
GNU AFFERO GENERAL PUBLIC LICENSE MIT License
Version 3, 19 November 2007
Copyright (c) 2020-2021 First Look Media
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Permission is hereby granted, free of charge, to any person obtaining a copy
of this license document, but changing it is not allowed. of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
Preamble to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
The GNU Affero General Public License is a free, copyleft license for furnished to do so, subject to the following conditions:
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software. The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
our General Public Licenses are intended to guarantee your freedom to IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
share and change all versions of a program--to make sure it remains free FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
software for all its users. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
When we speak of free software, we are referring to freedom, not OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
price. Our General Public Licenses are designed to make sure that you SOFTWARE.
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View file

@ -1,90 +0,0 @@
LARGE_TEST_REPO_DIR:=tests/test_docs_large
GIT_DESC=$$(git describe)
JUNIT_FLAGS := --capture=sys -o junit_logging=all
MYPY_ARGS := --ignore-missing-imports \
--disallow-incomplete-defs \
--disallow-untyped-defs \
--show-error-codes \
--warn-unreachable \
--warn-unused-ignores \
--exclude $(LARGE_TEST_REPO_DIR)/*.py
.PHONY: lint
lint: ## Check the code for linting, formatting, and typing issues with ruff and mypy
ruff check
ruff format --check
mypy $(MYPY_ARGS) dangerzone
mypy $(MYPY_ARGS) tests
.PHONY: fix
fix: ## apply all the suggestions from ruff
ruff check --fix
ruff format
.PHONY: test
test: ## Run the tests
# Make each GUI test run as a separate process, to avoid segfaults due to
# shared state.
# See more in https://github.com/freedomofpress/dangerzone/issues/493
pytest --co -q tests/gui | grep -e '^tests/' | xargs -n 1 pytest -v
pytest -v --cov --ignore dev_scripts --ignore tests/gui --ignore tests/test_large_set.py
.PHONY: test-large-requirements
test-large-requirements:
@git-lfs --version || (echo "ERROR: you need to install 'git-lfs'" && false)
@xmllint --version || (echo "ERROR: you need to install 'xmllint'" && false)
test-large-init: test-large-requirements
@echo "initializing 'test_docs_large' submodule"
git submodule init $(LARGE_TEST_REPO_DIR)
git submodule update $(LARGE_TEST_REPO_DIR)
cd $(LARGE_TEST_REPO_DIR) && $(MAKE) clone-docs
TEST_LARGE_RESULTS:=$(LARGE_TEST_REPO_DIR)/results/junit/commit_$(GIT_DESC).junit.xml
.PHONY: test-large
test-large: test-large-init ## Run large test set
python -m pytest --tb=no tests/test_large_set.py::TestLargeSet -v $(JUNIT_FLAGS) --junitxml=$(TEST_LARGE_RESULTS)
python $(TEST_LARGE_RESULTS)/report.py $(TEST_LARGE_RESULTS)
Dockerfile: Dockerfile.env Dockerfile.in ## Regenerate the Dockerfile from its template
poetry run jinja2 Dockerfile.in Dockerfile.env > Dockerfile
.PHONY: poetry-install
poetry-install: ## Install project dependencies
poetry install
.PHONY: build-clean
build-clean:
poetry run doit clean
.PHONY: build-macos-intel
build-macos-intel: build-clean poetry-install ## Build macOS intel package (.dmg)
poetry run doit -n 8
.PHONY: build-macos-arm
build-macos-arm: build-clean poetry-install ## Build macOS Apple Silicon package (.dmg)
poetry run doit -n 8 macos_build_dmg
.PHONY: build-linux
build-linux: build-clean poetry-install ## Build linux packages (.rpm and .deb)
poetry run doit -n 8 fedora_rpm debian_deb
.PHONY: regenerate-reference-pdfs
regenerate-reference-pdfs: ## Regenerate the reference PDFs
pytest tests/test_cli.py -k regenerate --generate-reference-pdfs
# Makefile self-help borrowed from the securedrop-client project
# Explaination of the below shell command should it ever break.
# 1. Set the field separator to ": ##" and any make targets that might appear between : and ##
# 2. Use sed-like syntax to remove the make targets
# 3. Format the split fields into $$1) the target name (in blue) and $$2) the target descrption
# 4. Pass this file as an arg to awk
# 5. Sort it alphabetically
# 6. Format columns with colon as delimiter.
.PHONY: help
help: ## Print this message and exit.
@printf "Makefile for developing and testing dangerzone.\n"
@printf "Subcommands:\n\n"
@awk 'BEGIN {FS = ":.*?## "} /^[0-9a-zA-Z_-]+:.*?## / {printf "\033[36m%s\033[0m : %s\n", $$1, $$2}' $(MAKEFILE_LIST) \
| sort \
| column -s ':' -t

197
QA.md
View file

@ -1,197 +0,0 @@
## QA
To ensure that new releases do not introduce regressions, and support existing
and newer platforms, we have to test that the produced packages work as expected.
Check the following:
- [ ] Make sure that the tip of the `main` branch passes the CI tests.
- [ ] Make sure that the Apple account has a valid application password and has
agreed to the latest Apple terms (see [macOS release](#macos-release)
section).
Because it is repetitive, we wrote a script to help with the QA.
It can run the tasks for you, pausing when it needs manual intervention.
You can run it with a command like:
```bash
poetry run ./dev_scripts/qa.py {distro}-{version}
```
### The checklist
- [ ] Create a test build in Windows and make sure it works:
- [ ] Check if the suggested Python version is still supported.
- [ ] Create a new development environment with Poetry.
- [ ] Build the container image and ensure the development environment uses
the new image.
- [ ] Download the OCR language data using `./install/common/download-tessdata.py`
- [ ] Run the Dangerzone tests.
- [ ] Build and run the Dangerzone .exe
- [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below).
- [ ] Create a test build in macOS (Intel CPU) and make sure it works:
- [ ] Check if the suggested Python version is still supported.
- [ ] Create a new development environment with Poetry.
- [ ] Build the container image and ensure the development environment uses
the new image.
- [ ] Download the OCR language data using `./install/common/download-tessdata.py`
- [ ] Run the Dangerzone tests.
- [ ] Create and run an app bundle.
- [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below).
- [ ] Create a test build in macOS (M1/2 CPU) and make sure it works:
- [ ] Check if the suggested Python version is still supported.
- [ ] Create a new development environment with Poetry.
- [ ] Build the container image and ensure the development environment uses
the new image.
- [ ] Download the OCR language data using `./install/common/download-tessdata.py`
- [ ] Run the Dangerzone tests.
- [ ] Create and run an app bundle.
- [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below).
- [ ] Create a test build in the most recent Ubuntu LTS platform (Ubuntu 24.04
as of writing this) and make sure it works:
- [ ] Create a new development environment with Poetry.
- [ ] Build the container image and ensure the development environment uses
the new image.
- [ ] Download the OCR language data using `./install/common/download-tessdata.py`
- [ ] Run the Dangerzone tests.
- [ ] Create a .deb package and install it system-wide.
- [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below).
- [ ] Create a test build in the most recent Fedora platform (Fedora 41 as of
writing this) and make sure it works:
- [ ] Create a new development environment with Poetry.
- [ ] Build the container image and ensure the development environment uses
the new image.
- [ ] Download the OCR language data using `./install/common/download-tessdata.py`
- [ ] Run the Dangerzone tests.
- [ ] Create an .rpm package and install it system-wide.
- [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below).
- [ ] Create a test build in the most recent Qubes Fedora template (Fedora 40 as
of writing this) and make sure it works:
- [ ] Create a new development environment with Poetry.
- [ ] Run the Dangerzone tests.
- [ ] Create a Qubes .rpm package and install it system-wide.
- [ ] Ensure that the Dangerzone application appears in the "Applications"
tab.
- [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below) and make sure
they spawn disposable qubes.
### Scenarios
#### 1. Dangerzone correctly identifies that Docker/Podman is not installed
_(Only for MacOS / Windows)_
Temporarily hide the Docker/Podman binaries, e.g., rename the `docker` /
`podman` binaries to something else. Then run Dangerzone. Dangerzone should
prompt the user to install Docker/Podman.
#### 2. Dangerzone correctly identifies that Docker is not running
_(Only for MacOS / Windows)_
Stop the Docker Desktop application. Then run Dangerzone. Dangerzone should
prompt the user to start Docker Desktop.
#### 3. Updating Dangerzone handles external state correctly.
_(Applies to Windows/MacOS)_
Install the previous version of Dangerzone, downloaded from the website.
Open the Dangerzone application and enable some non-default settings.
**If there are new settings, make sure to change those as well**.
Close the Dangerzone application and get the container image for that
version. For example:
```
$ docker images dangerzone.rocks/dangerzone
REPOSITORY TAG IMAGE ID CREATED SIZE
dangerzone.rocks/dangerzone <tag> <image ID> <date> <size>
```
Then run the version under QA and ensure that the settings remain changed.
Afterwards check that new docker image was installed by running the same command
and seeing the following differences:
```
$ docker images dangerzone.rocks/dangerzone
REPOSITORY TAG IMAGE ID CREATED SIZE
dangerzone.rocks/dangerzone <other tag> <different ID> <newer date> <different size>
```
#### 4. Dangerzone successfully installs the container image
_(Only for Linux)_
Remove the Dangerzone container image from Docker/Podman. Then run Dangerzone.
Dangerzone should install the container image successfully.
#### 5. Dangerzone retains the settings of previous runs
Run Dangerzone and make some changes in the settings (e.g., change the OCR
language, toggle whether to open the document after conversion, etc.). Restart
Dangerzone. Dangerzone should show the settings that the user chose.
#### 6. Dangerzone reports failed conversions
Run Dangerzone and convert the `tests/test_docs/sample_bad_pdf.pdf` document.
Dangerzone should fail gracefully, by reporting that the operation failed, and
showing the following error message:
> The document format is not supported
#### 7. Dangerzone succeeds in converting multiple documents
Run Dangerzone against a list of documents, and tick all options. Ensure that:
* Conversions take place sequentially.
* Attempting to close the window while converting asks the user if they want to
abort the conversions.
* Conversions are completed successfully.
* Conversions show individual progress in real-time (double-check for Qubes).
* _(Only for Linux)_ The resulting files open with the PDF viewer of our choice.
* OCR seems to have detected characters in the PDF files.
* The resulting files have been saved with the proper suffix, in the proper
location.
* The original files have been saved in the `unsafe/` directory.
#### 8. Dangerzone is able to handle drag-n-drop
Run Dangerzone against a set of documents that you drag-n-drop. Files should be
added and conversion should run without issue.
> [!TIP]
> On our end-user container environments for Linux, we can start a file manager
> with `thunar &`.
#### 9. Dangerzone CLI succeeds in converting multiple documents
_(Only for Windows and Linux)_
Run Dangerzone CLI against a list of documents. Ensure that conversions happen
sequentially, are completed successfully, and we see their progress.
#### 10. Dangerzone can open a document for conversion via right-click -> "Open With"
_(Only for Windows, MacOS and Qubes)_
Go to a directory with office documents, right-click on one, and click on "Open
With". We should be able to open the file with Dangerzone, and then convert it.
#### 11. Dangerzone shows helpful errors for setup issues on Qubes
_(Only for Qubes)_
Check what errors does Dangerzone throw in the following scenarios. The errors
should point the user to the Qubes notifications in the top-right corner:
1. The `dz-dvm` template does not exist. We can trigger this scenario by
temporarily renaming this template.
2. The Dangerzone RPC policy does not exist. We can trigger this scenario by
temporarily renaming the `dz.Convert` policy.
3. The `dz-dvm` disposable Qube cannot start due to insufficient resources. We
can trigger this scenario by temporarily increasing the minimum required RAM
of the `dz-dvm` template to more than the available amount.

View file

@ -2,32 +2,24 @@
Take potentially dangerous PDFs, office documents, or images and convert them to a safe PDF. Take potentially dangerous PDFs, office documents, or images and convert them to a safe PDF.
![Settings](./assets/screenshot1.png)
![Converting](./assets/screenshot2.png)
| ![Settings](./assets/screenshot1.png) | ![Converting](./assets/screenshot2.png) Dangerzone works like this: You give it a document that you don't know if you can trust (for example, an email attachment). Inside of a sandbox, Dangerzone converts the document to a PDF (if it isn't already one), and then converts the PDF into raw pixel data: a huge list of of RGB color values for each page. Then, in a separate sandbox, Dangerzone takes this pixel data and converts it back into a PDF.
|--|--|
Dangerzone works like this: You give it a document that you don't know if you can trust (for example, an email attachment). Inside of a sandbox, Dangerzone converts the document to a PDF (if it isn't already one), and then converts the PDF into raw pixel data: a huge list of RGB color values for each page. Then, outside of the sandbox, Dangerzone takes this pixel data and converts it back into a PDF. _Read more about Dangerzone in the blog post [Dangerzone: Working With Suspicious Documents Without Getting Hacked](https://tech.firstlook.media/dangerzone-working-with-suspicious-documents-without-getting-hacked)._
_Read more about Dangerzone in the [official site](https://dangerzone.rocks/about/)._
## Getting started ## Getting started
Follow the instructions for each platform: - Download [Dangerzone 0.3 for Mac](https://github.com/firstlookmedia/dangerzone/releases/download/v0.3/Dangerzone-0.3.dmg)
- Download [Dangerzone 0.3 for Windows](https://github.com/firstlookmedia/dangerzone/releases/download/v0.3/Dangerzone-0.3.msi)
- See [installing Dangerzone](https://github.com/firstlookmedia/dangerzone/wiki/Installing-Dangerzone) on the wiki for Linux repositories
* [macOS](https://github.com/freedomofpress/dangerzone/blob/v0.9.0/INSTALL.md#macos) You can also install Dangerzone for Mac using [Homebrew](https://brew.sh/): `brew install --cask dangerzone`
* [Windows](https://github.com/freedomofpress/dangerzone/blob/v0.9.0//INSTALL.md#windows)
* [Ubuntu Linux](https://github.com/freedomofpress/dangerzone/blob/v0.9.0/INSTALL.md#ubuntu-debian)
* [Debian Linux](https://github.com/freedomofpress/dangerzone/blob/v0.9.0/INSTALL.md#ubuntu-debian)
* [Fedora Linux](https://github.com/freedomofpress/dangerzone/blob/v0.9.0/INSTALL.md#fedora)
* [Qubes OS (beta)](https://github.com/freedomofpress/dangerzone/blob/v0.9.0/INSTALL.md#qubes-os)
* [Tails](https://github.com/freedomofpress/dangerzone/blob/v0.9.0/INSTALL.md#tails)
You can read more about our operating system support [here](https://github.com/freedomofpress/dangerzone/blob/v0.9.0/INSTALL.md#operating-system-support).
## Some features ## Some features
- Sandboxes don't have network access, so if a malicious document can compromise one, it can't phone home - Sandboxes don't have network access, so if a malicious document can compromise one, it can't phone home
- Sandboxes use [gVisor](https://gvisor.dev/), an application kernel written in Go, that implements a substantial portion of the Linux system call interface.
- Dangerzone can optionally OCR the safe PDFs it creates, so it will have a text layer again - Dangerzone can optionally OCR the safe PDFs it creates, so it will have a text layer again
- Dangerzone compresses the safe PDF to reduce file size - Dangerzone compresses the safe PDF to reduce file size
- After converting, Dangerzone lets you open the safe PDF in the PDF viewer of your choice, which allows you to open PDFs and office docs in Dangerzone by default so you never accidentally open a dangerous document - After converting, Dangerzone lets you open the safe PDF in the PDF viewer of your choice, which allows you to open PDFs and office docs in Dangerzone by default so you never accidentally open a dangerous document
@ -42,47 +34,10 @@ Dangerzone can convert these types of document into safe PDFs:
- ODF Spreadsheet (`.ods`) - ODF Spreadsheet (`.ods`)
- ODF Presentation (`.odp`) - ODF Presentation (`.odp`)
- ODF Graphics (`.odg`) - ODF Graphics (`.odg`)
- Hancom HWP (Hangul Word Processor) (`.hwp`, `.hwpx`)
* Not supported on
[Qubes OS](https://github.com/freedomofpress/dangerzone/issues/494)
- EPUB (`.epub`)
- Jpeg (`.jpg`, `.jpeg`) - Jpeg (`.jpg`, `.jpeg`)
- GIF (`.gif`) - GIF (`.gif`)
- PNG (`.png`) - PNG (`.png`)
- SVG (`.svg`)
- other image formats (`.bmp`, `.pnm`, `.pbm`, `.ppm`)
Dangerzone was inspired by [Qubes trusted PDF](https://blog.invisiblethings.org/2013/02/21/converting-untrusted-pdfs-into-trusted.html), but it works in non-Qubes operating systems. It uses containers as sandboxes instead of virtual machines (using Docker for macOS and Windows, and [podman](https://podman.io/) on Linux). Dangerzone was inspired by [Qubes trusted PDF](https://blog.invisiblethings.org/2013/02/21/converting-untrusted-pdfs-into-trusted.html), but it works in non-Qubes operating systems. It uses containers as sandboxes instead of virtual machines (using Docker for macOS, Windows, and Debian/Ubuntu, and [podman](https://podman.io/) for Fedora).
Set up a development environment by following [these instructions](/BUILD.md). Set up a development environment by following [these instructions](/BUILD.md).
# License and Copyright
Licensed under the AGPLv3: [https://opensource.org/licenses/agpl-3.0](https://opensource.org/licenses/agpl-3.0)
Copyright (c) 2022-2024 Freedom of the Press Foundation and Dangerzone contributors
Copyright (c) 2020-2021 First Look Media
## See also
* [GIJN Toolbox: Cutting-Edge — and Free — Online Investigative Tools You Can Try Right Now](https://gijn.org/stories/cutting-edge-free-online-investigative-tools/)
* [When security matters: working with Qubes OS at the Guardian](https://www.theguardian.com/info/2024/apr/04/when-security-matters-working-with-qubes-os-at-the-guardian)
## FAQ
### Has Dangerzone received a security audit?
Yes, Dangerzone received its [first security audit](https://freedom.press/news/dangerzone-receives-favorable-audit/) by [Include Security](https://includesecurity.com/) in December 2023. The audit was generally favorable, as it didn't identify any high-risk findings, except for 3 low-risk and 7 informational findings.
### "I'm experiencing an issue while using Dangerzone."
Dangerzone gets updates to improve its features _and_ to fix problems. So, updating may be the simplest path to resolving the issue which brought you here. Here is how to update:
1. Check which version of Dangerzone you are currently using: run Dangerzone, then look for a series of numbers to the right of the logo within the app. The format of the numbers will look similar to `0.4.1`
2. Now find the latest available version of Dangerzone: go to the [download page](https://dangerzone.rocks/#downloads). Look for the version number displayed. The number will be using the same format as in Step 1.
3. Is the version on the Dangerzone download page higher than the version of your installed app? Go ahead and update.
### Can I use Podman Desktop?
Yes! We've introduced [experimental support for Podman Desktop](https://github.com/freedomofpress/dangerzone/blob/main/docs/podman-desktop.md) on Windows and macOS.

View file

@ -1,350 +1,80 @@
# Release instructions # Release instructions
This section documents how we currently release Dangerzone for the different distributions we support. This section documents the release process. Unless you're a dangerzone developer making a release, you'll probably never need to follow it.
## Pre-release ## Changelog, version, and signed git tag
Here is a list of tasks that should be done before issuing the release: Before making a release, all of these should be complete:
- [ ] Create a new issue named **QA and Release for version \<VERSION\>**, to track the general progress.
You can generate its content with the the `poetry run ./dev_scripts/generate-release-tasks.py` command.
- [ ] [Add new Linux platforms and remove obsolete ones](https://github.com/freedomofpress/dangerzone/blob/main/RELEASE.md#add-new-linux-platforms-and-remove-obsolete-ones)
- [ ] Bump the Python dependencies using `poetry lock`
- [ ] Check for new [WiX releases](https://github.com/wixtoolset/wix/releases) and update it if needed
- [ ] Update `version` in `pyproject.toml` - [ ] Update `version` in `pyproject.toml`
- [ ] Update `share/version.txt` - [ ] Update `share/version.txt`
- [ ] Update the "Version" field in `install/linux/dangerzone.spec` - [ ] Update version and download links in `README.md`, and screenshot if necessary
- [ ] Bump the Debian version by adding a new changelog entry in `debian/changelog`
- [ ] [Bump the minimum Docker Desktop versions](https://github.com/freedomofpress/dangerzone/blob/main/RELEASE.md#bump-the-minimum-docker-desktop-version) in `isolation_provider/container.py`
- [ ] Bump the dates and versions in the `Dockerfile`
- [ ] Update the download links in our `INSTALL.md` page to point to the new version (the download links will be populated after the release)
- [ ] Update screenshot in `README.md`, if necessary
- [ ] CHANGELOG.md should be updated to include a list of all major changes since the last release - [ ] CHANGELOG.md should be updated to include a list of all major changes since the last release
- [ ] A draft release should be created. Copy the release notes text from the template at [`docs/templates/release-notes`](https://github.com/freedomofpress/dangerzone/tree/main/docs/templates/) - [ ] In `.circleci/config.yml`, add new platforms and remove obsolete platforms
- [ ] Send the release notes to editorial for review - [ ] Create a test build in Windows and make sure it works
- [ ] Do the QA tasks - [ ] Create a test build in mcaOS and make sure it works
- [ ] There must be a PGP-signed git tag for the version, e.g. for dangerzone 0.1.0, the tag must be `v0.1.0`
## Add new Linux platforms and remove obsolete ones Before making a release, verify the release git tag:
Our currently supported Linux OSes are Debian, Ubuntu, Fedora (we treat Qubes OS ```
as a special case of Fedora, release-wise). For each of these platforms, we need git fetch
to check if a new version has been added, or if an existing one is now EOL git tag -v v$VERSION
(https://endoflife.date/ is handy for this purpose). ```
In case of a new version (beta, RC, or official release): If the tag verifies successfully and check it out:
1. Add it in our CI workflows, to test if that version works. ```
* See `.circleci/config.yml` and `.github/workflows/ci.yml`, as well as git checkout v$VERSION
`dev_scripts/env.py` and `dev_scripts/qa.py`. ```
2. Do a test of this version locally with `dev_scripts/qa.py`. Focus on the
GUI part, since the basic functionality is already tested by our CI
workflows.
3. Add the new version in our `INSTALL.md` document, and drop a line in our
`CHANGELOG.md`.
4. If that version is a new stable release, update the `RELEASE.md` and
`BUILD.md` files where necessary.
4. Send a PR with the above changes.
In case of the removal of a version: ## macOS release
1. Remove any mention to this version from our repo. To make a macOS release, go to macOS build machine:
* Consult the previous paragraph, but also `grep` your way around.
2. Add a notice in our `CHANGELOG.md` about the version removal.
## Bump the minimum Docker Desktop version
We embed the minimum docker desktop versions inside Dangerzone, as an incentive for our macOS and Windows users to upgrade to the latests version.
You can find the latest version at the time of the release by looking at [their release notes](https://docs.docker.com/desktop/release-notes/)
## Large Document Testing
Parallel to the QA process, the release candidate should be put through the large document tests in a dedicated machine to run overnight.
Follow the instructions in `docs/developer/TESTING.md` to run the tests.
These tests will identify any regressions or progression in terms of document coverage.
## Release
Once we are confident that the release will be out shortly, and doesn't need any more changes:
- [ ] Create a PGP-signed git tag for the version, e.g., for dangerzone `v0.1.0`:
```bash
git tag -s v0.1.0
git push origin v0.1.0
```
**Note**: release candidates are suffixed by `-rcX`.
> [!IMPORTANT]
> Because we don't have [reproducible builds](https://github.com/freedomofpress/dangerzone/issues/188)
> yet, building the Dangerzone container image in various platforms would lead
> to different container image IDs / hashes, due to different timestamps. To
> avoid this issue, we should build the final container image for x86_64
> architectures on **one** platform, and then copy it to the rest of the
> platforms, before creating our .deb / .rpm / .msi / app bundles.
### macOS Release
> [!TIP]
> You can automate these steps from your macOS terminal app with:
>
> ```
> export APPLE_ID=<email>
> make build-macos-intel # for Intel macOS
> make build-macos-arm # for Apple Silicon macOS
> ```
The following needs to happen for both Silicon and Intel chipsets.
#### Initial Setup
- Build machine must have: - Build machine must have:
- Apple-trusted `Developer ID Application: Freedom of the Press Foundation (94ZZGGGJ3W)` code-signing certificates installed - macOS 10.14
- Apple account must have: - Apple-trusted `Developer ID Application: FIRST LOOK PRODUCTIONS, INC.` and `Developer ID Installer: FIRST LOOK PRODUCTIONS, INC.` code-signing certificates installed
- A valid application password for `notarytool` in the Keychain. You can verify - An app-specific Apple ID password saved in the login keychain called `flockagent-notarize`
this by running: `xcrun notarytool history --apple-id "<email>" --keychain-profile "dz-notarytool-release-key"`. If you don't find it, you can add it to the Keychain by running - Verify and checkout the git tag for this release
`xcrun notarytool store-credentials dz-notarytool-release-key --apple-id <email> --team-id <team ID>` - Run `poetry install`
with the respective `email` and `team ID` (the latter can be obtained [here](https://developer.apple.com/help/account/manage-your-team/locate-your-team-id)) - Run `poetry run ./install/macos/build_app.py --with-codesign`; this will make `dist/Dangerzone.dmg`
- Agreed to any new terms and conditions. You can find those if you visit - Notarize it: `xcrun altool --notarize-app --primary-bundle-id "media.firstlook.dangerzone" -u "micah@firstlook.org" -p "@keychain:dangerzone-notarize" --file dist/Dangerzone.dmg`
https://developer.apple.com and login with the proper Apple ID. - Wait for it to get approved, check status with: `xcrun altool --notarization-history 0 -u "micah@firstlook.org" -p "@keychain:dangerzone-notarize"`
- (If it gets rejected, you can see why with: `xcrun altool --notarization-info [RequestUUID] -u "micah@firstlook.org" -p "@keychain:dangerzone-notarize"`)
- After it's approved, staple the ticket: `xcrun stapler staple dist/Dangerzone.dmg`
#### Releasing and Signing This process ends up with the final file:
Here is what you need to do: ```
dist/Dangerzone.dmg
```
- [ ] Verify and install the latest supported Python version from Rename `Dangerzone.dmg` to `Dangerzone-$VERSION.dmg`.
[python.org](https://www.python.org/downloads/macos/) (do not use the one from
brew as it is known to [cause issues](https://github.com/freedomofpress/dangerzone/issues/471))
- [ ] Checkout the dependencies, and clean your local copy: ## Windows release
```bash To make a Windows release, go to the Windows build machine:
# In case of a new Python installation or minor version upgrade, e.g., from - Build machine should be running Windows 10, and have the Windows codesigning certificate installed
# 3.11 to 3.12, reinstall Poetry - Verify and checkout the git tag for this release
python3 -m pip install poetry - Run `poetry install`
- Run `poetry shell`, then `cd ..\pyinstaller`, `python setup.py install`, `exit`
# You can verify the correct Python version is used - Run `poetry run install\windows\step1-build-exe.bat`
poetry debug info - Open a second command prompt _as an administratror_, cd to the dangerzone directory, and run: `install\windows\step2-make-symlink.bat`
- Back in the first command prompt, run: `poetry run install\windows\step3-build-installer.bat`
# Replace with the actual version - When you're done you will have `dist\Dangerzone.msi`
export DZ_VERSION=$(cat share/version.txt)
# Verify and checkout the git tag for this release:
git checkout -f v$VERSION
# Clean the git repository
git clean -df
# Clean up the environment
poetry env remove --all
# Install the dependencies
poetry sync
```
- [ ] Build the container image and the OCR language data
```bash
poetry run ./install/common/build-image.py
poetry run ./install/common/download-tessdata.py
# Copy the container image to the assets folder
cp share/container.tar ~dz/release-assets/$VERSION/dangerzone-$VERSION-arm64.tar
cp share/image-id.txt ~dz/release-assets/$VERSION/.
```
- [ ] Build the app bundle
```bash
poetry run ./install/macos/build-app.py
```
- [ ] Sign the application bundle, and notarize it
You need to run this command as the account that has access to the code signing certificate
This command assumes that you have created, and stored in the Keychain, an
application password associated with your Apple Developer ID, which will be
used specifically for `notarytool`.
```bash
# Sign the .App and make it a .dmg
poetry run ./install/macos/build-app.py --only-codesign
# Notarize it. You must run this command from the MacOS UI
# from a terminal application.
xcrun notarytool submit ./dist/Dangerzone.dmg --apple-id $APPLE_ID --keychain-profile "dz-notarytool-release-key" --wait && xcrun stapler staple dist/Dangerzone.dmg
# Copy the .dmg to the assets folder
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then
ARCH="i686"
fi
cp dist/Dangerzone.dmg ~dz/release-assets/$VERSION/Dangerzone-$VERSION-$ARCH.dmg
```
### Windows Release
The Windows release is performed in a Windows 11 virtual machine (as opposed to a physical one).
#### Initial Setup
- Download a VirtualBox VM image for Windows from here: https://developer.microsoft.com/en-us/windows/downloads/virtual-machines/ and import it into VirtualBox. Also install the Oracle VM VirtualBox Extension Pack.
- Install updates
- Install git for Windows from https://git-scm.com/download/win, and clone the dangerzone repo
- Follow the Windows build instructions in `BUILD.md`, except:
- Don't install Docker Desktop (it won't work without nested virtualization)
- Install the Windows SDK from here: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/ and add `C:\Program Files (x86)\Microsoft SDKs\ClickOnce\SignTool` to the path (you'll need it for `signtool.exe`)
- You'll also need the Windows codesigning certificate installed on the VM
#### Releasing and Signing
- [ ] Checkout the dependencies, and clean your local copy:
```bash
# In case of a new Python installation or minor version upgrade, e.g., from
# 3.11 to 3.12, reinstall Poetry
python3 -m pip install poetry
# You can verify the correct Python version is used
poetry debug info
# Replace with the actual version
export DZ_VERSION=$(cat share/version.txt)
# Verify and checkout the git tag for this release:
git checkout -f v$VERSION
# Clean the git repository
git clean -df
# Clean up the environment
poetry env remove --all
# Install the dependencies
poetry sync
```
- [ ] Copy the container image into the VM
> [!IMPORTANT]
> Instead of running `python .\install\windows\build-image.py` in the VM, run the build image script on the host (making sure to build for `linux/amd64`). Copy `share/container.tar` and `share/image-id.txt` from the host into the `share` folder in the VM.
- [ ] Run `poetry run .\install\windows\build-app.bat`
- [ ] When you're done you will have `dist\Dangerzone.msi`
Rename `Dangerzone.msi` to `Dangerzone-$VERSION.msi`. Rename `Dangerzone.msi` to `Dangerzone-$VERSION.msi`.
### Linux release ## Linux release
> [!TIP] Linux binaries are automatically built and deployed to repositories when a new tag is pushed.
> You can automate these steps from any Linux distribution with:
>
> ```
> make build-linux
> ```
>
> You can then add the created artifacts to the appropriate APT/YUM repo.
Below we explain how we build packages for each Linux distribution we support. ## Publishing the release
#### Debian/Ubuntu To publish the release:
Because the Debian packages do not contain compiled Python code for a specific - Create a new release on GitHub, put the changelog in the description of the release, and upload the macOS and Windows installers
Python version, we can create a single Debian package and use it for all of our - Update the [Installing Dangerzone](https://github.com/firstlookmedia/dangerzone/wiki/Installing-Dangerzone) wiki page
Debian-based distros. - Update the [Dangerzone website](https://github.com/firstlookmedia/dangerzone.rocks) to link to the new installers
Create a Debian Bookworm development environment. You can [follow the
instructions in our build section](https://github.com/freedomofpress/dangerzone/blob/main/BUILD.md#debianubuntu),
or create your own locally with:
```sh
# Create and run debian bookworm development environment
./dev_scripts/env.py --distro debian --version bookworm build-dev
./dev_scripts/env.py --distro debian --version bookworm run --dev bash
# Build the latest container
./dev_scripts/env.py --distro debian --version bookworm run --dev bash -c "cd dangerzone && poetry run ./install/common/build-image.py"
# Create a .deb
./dev_scripts/env.py --distro debian --version bookworm run --dev bash -c "cd dangerzone && ./install/linux/build-deb.py"
```
Publish the .deb under `./deb_dist` to the
[`freedomofpress/apt-tools-prod`](https://github.com/freedomofpress/apt-tools-prod)
repo, by sending a PR. Follow the instructions in that repo on how to do so.
#### Fedora
> **NOTE**: This procedure will have to be done for every supported Fedora version.
>
> In this section, we'll use Fedora 41 as an example.
Create a Fedora development environment. You can [follow the
instructions in our build section](https://github.com/freedomofpress/dangerzone/blob/main/BUILD.md#fedora),
or create your own locally with:
```sh
./dev_scripts/env.py --distro fedora --version 41 build-dev
# Build the latest container (skip if already built):
./dev_scripts/env.py --distro fedora --version 41 run --dev bash -c "cd dangerzone && poetry run ./install/common/build-image.py"
# Create a .rpm:
./dev_scripts/env.py --distro fedora --version 41 run --dev bash -c "cd dangerzone && ./install/linux/build-rpm.py"
```
Publish the .rpm under `./dist` to the
[`freedomofpress/yum-tools-prod`](https://github.com/freedomofpress/yum-tools-prod) repo, by sending a PR. Follow the instructions in that repo on how to do so.
#### Qubes
Create a .rpm for Qubes:
```sh
./dev_scripts/env.py --distro fedora --version 41 run --dev bash -c "cd dangerzone && ./install/linux/build-rpm.py --qubes"
```
and similarly publish it to the [`freedomofpress/yum-tools-prod`](https://github.com/freedomofpress/yum-tools-prod)
repo.
## Publishing the Release
To publish the release, you can follow these steps:
- [ ] Create an archive of the Dangerzone source in `tar.gz` format:
```bash
export VERSION=$(cat share/version.txt)
git archive --format=tar.gz -o dangerzone-${VERSION:?}.tar.gz --prefix=dangerzone/ v${VERSION:?}
```
- [ ] Run container scan on the produced container images (some time may have passed since the artifacts were built)
```bash
docker pull anchore/grype:latest
docker run --rm -v ./share/container.tar:/container.tar anchore/grype:latest /container.tar
```
- [ ] Collect the assets in a single directory, calculate their SHA-256 hashes, and sign them.
There is an `./dev_scripts/sign-assets.py` script to automate this task.
**Important:** Before running the script, make sure that it's the same container images as
the ones that are shipped in other platforms (see our [Pre-release](#Pre-release) section)
```bash
# Sign all the assets
./dev_scripts/sign-assets.py ~/release-assets/$VERSION/github --version $VERSION
```
- [ ] Upload all the assets to the draft release on GitHub.
```bash
find ~/release-assets/$VERSION/github | xargs -n1 ./dev_scripts/upload-asset.py --token ~/token --draft
```
- [ ] Update the [Dangerzone website](https://github.com/freedomofpress/dangerzone.rocks) to link to the new installers.
- [ ] Update the brew cask release of Dangerzone with a [PR like this one](https://github.com/Homebrew/homebrew-cask/pull/116319)
- [ ] Update version and links to our installation instructions (`INSTALL.md`) in `README.md`
## Post-release
- [ ] Toot release announcement on our mastodon account @dangerzone@fosstodon.org
- [ ] Extend the `check_repos.yml` CI test for the newly added platforms

View file

@ -1,14 +0,0 @@
This project includes third-party components as follows:
1. gVisor APT Key
- URL: https://gvisor.dev/archive.key
- Last updated: 2025-01-21
- Description: This is the public key used for verifying packages from the gVisor repository.
2. Reproducible Containers Helper Script
- URL: https://github.com/reproducible-containers/repro-sources-list.sh/blob/d15cf12b26395b857b24fba223b108aff1c91b26/repro-sources-list.sh
- Last updated: 2025-01-21
- Description: This script is used for building reproducible Debian images.
Please refer to the respective sources for licensing information and further details regarding the use of these components.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 53 KiB

102
container/Dockerfile Normal file
View file

@ -0,0 +1,102 @@
FROM alpine:latest
# Install dependencies
RUN apk -U upgrade && \
apk add \
ghostscript \
graphicsmagick \
libreoffice \
openjdk8 \
poppler-utils \
python3 \
py3-magic \
py3-pillow \
sudo \
tesseract-ocr \
tesseract-ocr-data-afr \
tesseract-ocr-data-ara \
tesseract-ocr-data-aze \
tesseract-ocr-data-bel \
tesseract-ocr-data-ben \
tesseract-ocr-data-bul \
tesseract-ocr-data-cat \
tesseract-ocr-data-ces \
tesseract-ocr-data-chi_sim \
tesseract-ocr-data-chi_tra \
tesseract-ocr-data-chr \
tesseract-ocr-data-dan \
tesseract-ocr-data-deu \
tesseract-ocr-data-ell \
tesseract-ocr-data-enm \
tesseract-ocr-data-epo \
tesseract-ocr-data-equ \
tesseract-ocr-data-est \
tesseract-ocr-data-eus \
tesseract-ocr-data-fin \
tesseract-ocr-data-fra \
tesseract-ocr-data-frk \
tesseract-ocr-data-frm \
tesseract-ocr-data-glg \
tesseract-ocr-data-grc \
tesseract-ocr-data-heb \
tesseract-ocr-data-hin \
tesseract-ocr-data-hrv \
tesseract-ocr-data-hun \
tesseract-ocr-data-ind \
tesseract-ocr-data-isl \
tesseract-ocr-data-ita \
tesseract-ocr-data-ita_old \
tesseract-ocr-data-jpn \
tesseract-ocr-data-kan \
tesseract-ocr-data-kat \
tesseract-ocr-data-kor \
tesseract-ocr-data-lav \
tesseract-ocr-data-lit \
tesseract-ocr-data-mal \
tesseract-ocr-data-mkd \
tesseract-ocr-data-mlt \
tesseract-ocr-data-msa \
tesseract-ocr-data-nld \
tesseract-ocr-data-nor \
tesseract-ocr-data-pol \
tesseract-ocr-data-por \
tesseract-ocr-data-ron \
tesseract-ocr-data-rus \
tesseract-ocr-data-slk \
tesseract-ocr-data-slv \
tesseract-ocr-data-spa \
tesseract-ocr-data-spa_old \
tesseract-ocr-data-sqi \
tesseract-ocr-data-srp \
tesseract-ocr-data-swa \
tesseract-ocr-data-swe \
tesseract-ocr-data-tam \
tesseract-ocr-data-tel \
tesseract-ocr-data-tgl \
tesseract-ocr-data-tha \
tesseract-ocr-data-tur \
tesseract-ocr-data-ukr \
tesseract-ocr-data-vie
# Install pdftk
RUN \
wget https://gitlab.com/pdftk-java/pdftk/-/jobs/924565145/artifacts/raw/build/libs/pdftk-all.jar && \
mv pdftk-all.jar /usr/local/bin && \
chmod +x /usr/local/bin/pdftk-all.jar && \
echo '#!/bin/sh' > /usr/local/bin/pdftk && \
echo '/usr/bin/java -jar "/usr/local/bin/pdftk-all.jar" "$@"' >> /usr/local/bin/pdftk && \
chmod +x /usr/local/bin/pdftk
COPY dangerzone.py /usr/local/bin/
RUN chmod +x /usr/local/bin/dangerzone.py
# Add the unprivileged user
RUN adduser -h /home/user -s /bin/sh -D user
# /tmp/input_file is where the first convert expects the input file to be, and
# /tmp where it will write the pixel files
#
# /dangerzone is where the second script expects files to be put by the first one
#
# /safezone is where the wrapper eventually moves the sanitized files.
VOLUME /dangerzone /tmp/input_file /safezone

541
container/dangerzone.py Normal file
View file

@ -0,0 +1,541 @@
#!/usr/bin/env python3
"""
Here are the steps, with progress bar percentages for each step:
document_to_pixels
- 0%-3%: Convert document into a PDF (skipped if the input file is a PDF)
- 3%-5%: Split PDF into individual pages, and count those pages
- 5%-50%: Convert each page into pixels (each page takes 45/n%, where n is the number of pages)
pixels_to_pdf:
- 50%-95%: Convert each page of pixels into a PDF (each page takes 45/n%, where n is the number of pages)
- 95%-100%: Compress the final PDF
"""
import sys
import subprocess
import glob
import os
import json
import shutil
import magic
from PIL import Image
class DangerzoneConverter:
def __init__(self):
pass
def document_to_pixels(self):
percentage = 0.0
conversions = {
# .pdf
"application/pdf": {"type": None},
# .docx
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
"type": "libreoffice",
"libreoffice_output_filter": "writer_pdf_Export",
},
# .doc
"application/msword": {
"type": "libreoffice",
"libreoffice_output_filter": "writer_pdf_Export",
},
# .docm
"application/vnd.ms-word.document.macroEnabled.12": {
"type": "libreoffice",
"libreoffice_output_filter": "writer_pdf_Export",
},
# .xlsx
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
"type": "libreoffice",
"libreoffice_output_filter": "calc_pdf_Export",
},
# .xls
"application/vnd.ms-excel": {
"type": "libreoffice",
"libreoffice_output_filter": "calc_pdf_Export",
},
# .pptx
"application/vnd.openxmlformats-officedocument.presentationml.presentation": {
"type": "libreoffice",
"libreoffice_output_filter": "impress_pdf_Export",
},
# .ppt
"application/vnd.ms-powerpoint": {
"type": "libreoffice",
"libreoffice_output_filter": "impress_pdf_Export",
},
# .odt
"application/vnd.oasis.opendocument.text": {
"type": "libreoffice",
"libreoffice_output_filter": "writer_pdf_Export",
},
# .odg
"application/vnd.oasis.opendocument.graphics": {
"type": "libreoffice",
"libreoffice_output_filter": "impress_pdf_Export",
},
# .odp
"application/vnd.oasis.opendocument.presentation": {
"type": "libreoffice",
"libreoffice_output_filter": "impress_pdf_Export",
},
# .ops
"application/vnd.oasis.opendocument.spreadsheet": {
"type": "libreoffice",
"libreoffice_output_filter": "calc_pdf_Export",
},
# .jpg
"image/jpeg": {"type": "convert"},
# .gif
"image/gif": {"type": "convert"},
# .png
"image/png": {"type": "convert"},
# .tif
"image/tiff": {"type": "convert"},
"image/x-tiff": {"type": "convert"},
}
# Detect MIME type
mime = magic.Magic(mime=True)
mime_type = mime.from_file("/tmp/input_file")
# Validate MIME type
if mime_type not in conversions:
self.output(True, "The document format is not supported", percentage)
return 1
# Convert input document to PDF
conversion = conversions[mime_type]
if conversion["type"] is None:
pdf_filename = "/tmp/input_file"
elif conversion["type"] == "libreoffice":
self.output(False, "Converting to PDF using LibreOffice", percentage)
args = [
"libreoffice",
"--headless",
"--convert-to",
f"pdf:{conversion['libreoffice_output_filter']}",
"--outdir",
"/tmp",
"/tmp/input_file",
]
try:
p = subprocess.run(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=60,
)
except subprocess.TimeoutExpired:
self.output(
True,
"Error converting document to PDF, LibreOffice timed out after 60 seconds",
percentage,
)
return 1
if p.returncode != 0:
self.output(
True,
f"Conversion to PDF with LibreOffice failed",
percentage,
)
return 1
pdf_filename = "/tmp/input_file.pdf"
elif conversion["type"] == "convert":
self.output(False, "Converting to PDF using GraphicsMagick", percentage)
args = [
"gm",
"convert",
"/tmp/input_file",
"/tmp/input_file.pdf",
]
try:
p = subprocess.run(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=60,
)
except subprocess.TimeoutExpired:
self.output(
True,
"Error converting document to PDF, GraphicsMagick timed out after 60 seconds",
percentage,
)
return 1
if p.returncode != 0:
self.output(
True,
"Conversion to PDF with GraphicsMagick failed",
percentage,
)
return 1
pdf_filename = "/tmp/input_file.pdf"
else:
self.output(
True,
"Invalid conversion type",
percentage,
)
return 1
percentage += 3
# Separate PDF into pages
self.output(
False,
"Separating document into pages",
percentage,
)
args = ["pdftk", pdf_filename, "burst", "output", "/tmp/page-%d.pdf"]
try:
p = subprocess.run(
args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60
)
except subprocess.TimeoutExpired:
self.output(
True,
"Error separating document into pages, pdfseparate timed out after 60 seconds",
percentage,
)
return 1
if p.returncode != 0:
self.output(
True,
"Separating document into pages failed",
percentage,
)
return 1
page_filenames = glob.glob("/tmp/page-*.pdf")
percentage += 2
# Convert to RGB pixel data
percentage_per_page = 45.0 / len(page_filenames)
for page in range(1, len(page_filenames) + 1):
pdf_filename = f"/tmp/page-{page}.pdf"
png_filename = f"/tmp/page-{page}.png"
rgb_filename = f"/tmp/page-{page}.rgb"
width_filename = f"/tmp/page-{page}.width"
height_filename = f"/tmp/page-{page}.height"
filename_base = f"/tmp/page-{page}"
self.output(
False,
f"Converting page {page}/{len(page_filenames)} to pixels",
percentage,
)
# Convert to png
try:
p = subprocess.run(
["pdftocairo", pdf_filename, "-png", "-singlefile", filename_base],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=60,
)
except subprocess.TimeoutExpired:
self.output(
True,
"Error converting from PDF to PNG, pdftocairo timed out after 60 seconds",
percentage,
)
return 1
if p.returncode != 0:
self.output(
True,
"Conversion from PDF to PNG failed",
percentage,
)
return 1
# Save the width and height
im = Image.open(png_filename)
width, height = im.size
with open(width_filename, "w") as f:
f.write(str(width))
with open(height_filename, "w") as f:
f.write(str(height))
# Convert to RGB pixels
try:
p = subprocess.run(
[
"gm",
"convert",
png_filename,
"-depth",
"8",
f"rgb:{rgb_filename}",
],
timeout=60,
)
except subprocess.TimeoutExpired:
self.output(
True,
"Error converting from PNG to pixels, convert timed out after 60 seconds",
percentage,
)
return 1
if p.returncode != 0:
self.output(
True,
"Conversion from PNG to RGB failed",
percentage,
)
return 1
# Delete the png
os.remove(png_filename)
percentage += percentage_per_page
self.output(
False,
"Converted document to pixels",
percentage,
)
# Move converted files into /dangerzone
for filename in (
glob.glob("/tmp/page-*.rgb")
+ glob.glob("/tmp/page-*.width")
+ glob.glob("/tmp/page-*.height")
):
shutil.move(filename, "/dangerzone")
return 0
def pixels_to_pdf(self):
percentage = 50.0
num_pages = len(glob.glob("/dangerzone/page-*.rgb"))
# Convert RGB files to PDF files
percentage_per_page = 45.0 / num_pages
for page in range(1, num_pages + 1):
filename_base = f"/dangerzone/page-{page}"
rgb_filename = f"{filename_base}.rgb"
width_filename = f"{filename_base}.width"
height_filename = f"{filename_base}.height"
png_filename = f"/tmp/page-{page}.png"
ocr_filename = f"/tmp/page-{page}"
pdf_filename = f"/tmp/page-{page}.pdf"
with open(width_filename) as f:
width = f.read().strip()
with open(height_filename) as f:
height = f.read().strip()
if os.environ.get("OCR") == "1":
# OCR the document
self.output(
False,
f"Converting page {page}/{num_pages} from pixels to searchable PDF",
percentage,
)
args = [
"gm",
"convert",
"-size",
f"{width}x{height}",
"-depth",
"8",
f"rgb:{rgb_filename}",
f"png:{png_filename}",
]
try:
p = subprocess.run(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=60,
)
except subprocess.TimeoutExpired:
self.output(
True,
"Error converting pixels to PNG, convert timed out after 60 seconds",
percentage,
)
return 1
if p.returncode != 0:
self.output(
True,
f"Page {page}/{num_pages} conversion to PNG failed",
percentage,
)
return 1
args = [
"tesseract",
png_filename,
ocr_filename,
"-l",
os.environ.get("OCR_LANGUAGE"),
"--dpi",
"70",
"pdf",
]
try:
p = subprocess.run(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=60,
)
except subprocess.TimeoutExpired:
self.output(
True,
"Error converting PNG to searchable PDF, tesseract timed out after 60 seconds",
percentage,
)
return 1
if p.returncode != 0:
self.output(
True,
f"Page {page}/{num_pages} OCR failed",
percentage,
)
return 1
else:
# Don't OCR
self.output(
False,
f"Converting page {page}/{num_pages} from pixels to PDF",
percentage,
)
args = [
"gm",
"convert",
"-size",
f"{width}x{height}",
"-depth",
"8",
f"rgb:{rgb_filename}",
f"pdf:{pdf_filename}",
]
try:
p = subprocess.run(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=60,
)
except subprocess.TimeoutExpired:
self.output(
True,
"Error converting RGB to PDF, convert timed out after 60 seconds",
percentage,
)
return 1
if p.returncode != 0:
self.output(
True,
f"Page {page}/{num_pages} conversion to PDF failed",
percentage,
)
return 1
percentage += percentage_per_page
# Merge pages into a single PDF
self.output(
False,
f"Merging {num_pages} pages into a single PDF",
percentage,
)
args = ["pdfunite"]
for page in range(1, num_pages + 1):
args.append(f"/tmp/page-{page}.pdf")
args.append(f"/tmp/safe-output.pdf")
try:
p = subprocess.run(
args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60
)
except subprocess.TimeoutExpired:
self.output(
True,
"Error merging pages into a single PDF, pdfunite timed out after 60 seconds",
percentage,
)
return 1
if p.returncode != 0:
self.output(
True,
"Merging pages into a single PDF failed",
percentage,
)
return 1
percentage += 2
# Compress
self.output(
False,
f"Compressing PDF",
percentage,
)
compress_timeout = num_pages * 3
try:
p = subprocess.run(
["ps2pdf", "/tmp/safe-output.pdf", "/tmp/safe-output-compressed.pdf"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=compress_timeout,
)
except subprocess.TimeoutExpired:
self.output(
True,
f"Error compressing PDF, ps2pdf timed out after {compress_timeout} seconds",
percentage,
)
return 1
if p.returncode != 0:
self.output(
True,
f"Compressing PDF failed",
percentage,
)
return 1
percentage = 100.0
self.output(False, "Safe PDF created", percentage)
# Move converted files into /safezone
shutil.move("/tmp/safe-output.pdf", "/safezone")
shutil.move("/tmp/safe-output-compressed.pdf", "/safezone")
return 0
def output(self, error, text, percentage):
print(json.dumps({"error": error, "text": text, "percentage": int(percentage)}))
sys.stdout.flush()
def main():
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} [document-to-pixels]|[pixels-to-pdf]")
return -1
converter = DangerzoneConverter()
if sys.argv[1] == "document-to-pixels":
return converter.document_to_pixels()
if sys.argv[1] == "pixels-to-pdf":
return converter.pixels_to_pdf()
return -1
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,25 +1,6 @@
import logging
import os import os
import sys import sys
logger = logging.getLogger(__name__)
# Call freeze_support() to avoid passing unknown options to the subprocess.
# See https://github.com/freedomofpress/dangerzone/issues/873
import multiprocessing
multiprocessing.freeze_support()
try:
from . import vendor # type: ignore [attr-defined]
vendor_path: str = vendor.__path__[0]
logger.debug(f"Using vendored PyMuPDF libraries from '{vendor_path}'")
sys.path.insert(0, vendor_path)
except ImportError:
pass
if "DANGERZONE_MODE" in os.environ: if "DANGERZONE_MODE" in os.environ:
mode = os.environ["DANGERZONE_MODE"] mode = os.environ["DANGERZONE_MODE"]
else: else:
@ -32,4 +13,4 @@ else:
if mode == "cli": if mode == "cli":
from .cli import cli_main as main from .cli import cli_main as main
else: else:
from .gui import gui_main as main # noqa: F401 from .gui import gui_main as main

View file

@ -1,109 +0,0 @@
import functools
import os
import sys
from typing import List, Optional, Tuple
import click
from . import errors
from .document import Document
@errors.handle_document_errors
def _validate_input_filename(
ctx: click.Context, param: str, value: Optional[str]
) -> Optional[str]:
if value is None:
return None
filename = Document.normalize_filename(value)
Document.validate_input_filename(filename)
return filename
@errors.handle_document_errors
def _validate_input_filenames(
ctx: click.Context, param: List[str], value: Tuple[str]
) -> List[str]:
normalized_filenames = []
for filename in value:
filename = Document.normalize_filename(filename)
Document.validate_input_filename(filename)
normalized_filenames.append(filename)
return normalized_filenames
@errors.handle_document_errors
def _validate_output_filename(
ctx: click.Context, param: str, value: Optional[str]
) -> Optional[str]:
if value is None:
return None
filename = Document.normalize_filename(value)
Document.validate_output_filename(filename)
return filename
# XXX: Click versions 7.x and below inspect the number of arguments that the
# callback handler supports. Unfortunately, common Python decorators (such as
# `handle_document_errors()`) mask this number, so we need to reinstate it
# somehow [1]. The simplest way to do so is using a wrapper function.
#
# Once we stop supporting Click 7.x, we can remove the wrappers below.
#
# [1]: https://github.com/freedomofpress/dangerzone/issues/206#issuecomment-1297336863
def validate_input_filename(
ctx: click.Context, param: str, value: Optional[str]
) -> Optional[str]:
return _validate_input_filename(ctx, param, value)
def validate_input_filenames(
ctx: click.Context, param: List[str], value: Tuple[str]
) -> List[str]:
return _validate_input_filenames(ctx, param, value)
def validate_output_filename(
ctx: click.Context, param: str, value: Optional[str]
) -> Optional[str]:
return _validate_output_filename(ctx, param, value)
def check_suspicious_options(args: List[str]) -> None:
options = set([arg for arg in args if arg.startswith("-")])
try:
files = set(os.listdir())
except Exception:
# If we can list files in the current working directory, this means that
# we're probably in an unlinked directory. Dangerzone should still work in
# this case, so we should return here.
return
intersection = options & files
if intersection:
filenames_str = ", ".join(intersection)
msg = (
f"Security: Detected CLI options that are also present as files in the"
f" current working directory: {filenames_str}"
)
click.echo(msg)
sys.exit(1)
def override_parser_and_check_suspicious_options(click_main: click.Command) -> None:
"""Override the argument parsing logic of Click.
Click does not allow us to have access to the raw arguments that it receives (either
from sys.argv or from its testing module). To circumvent this, we can override its
`Command.parse_args()` method, which is public and should be safe to do so.
We can use it to check for any suspicious options prior to arg parsing.
"""
orig_parse_fn = click_main.parse_args
@functools.wraps(orig_parse_fn)
def custom_parse_fn(ctx: click.Context, args: List[str]) -> List[str]:
check_suspicious_options(args)
return orig_parse_fn(ctx, args)
click_main.parse_args = custom_parse_fn # type: ignore [method-assign]

View file

@ -1,355 +1,115 @@
import logging import os
import sys import sys
from typing import List, Optional import json
import click import click
from colorama import Back, Fore, Style from colorama import Fore, Style
from . import args, errors from .global_common import GlobalCommon
from .document import ARCHIVE_SUBDIR, SAFE_EXTENSION from .common import Common
from .isolation_provider.container import Container from .container import convert
from .isolation_provider.dummy import Dummy
from .isolation_provider.qubes import Qubes, is_qubes_native_conversion
from .logic import DangerzoneCore
from .settings import Settings
from .util import get_version, replace_control_chars
def print_header(s: str) -> None: def print_header(s):
click.echo("") click.echo("")
click.echo(Style.BRIGHT + s) click.echo(Style.BRIGHT + s)
@click.command() @click.command()
@click.option( @click.option("--output-filename", help="Default is filename ending with -safe.pdf")
"--output-filename",
callback=args.validate_output_filename,
help=f"Default is filename ending with {SAFE_EXTENSION}",
)
@click.option("--ocr-lang", help="Language to OCR, defaults to none") @click.option("--ocr-lang", help="Language to OCR, defaults to none")
@click.option( @click.argument("filename", required=True)
"--archive", def cli_main(output_filename, ocr_lang, filename):
"archive", global_common = GlobalCommon()
flag_value=True, common = Common()
help=f"Archives the unsafe version in a subdirectory named '{ARCHIVE_SUBDIR}'",
) global_common.display_banner()
@click.option(
"--unsafe-dummy-conversion", "dummy_conversion", flag_value=True, hidden=True # Validate filename
) valid = True
@click.argument( try:
"filenames", with open(os.path.abspath(filename), "rb") as f:
required=False, pass
nargs=-1, except:
type=click.UNPROCESSED, valid = False
callback=args.validate_input_filenames,
) if not valid:
@click.option( click.echo("Invalid filename")
"--debug", return
"debug",
flag_value=True, common.input_filename = os.path.abspath(filename)
help="Run Dangerzone in debug mode, to get logs from gVisor.",
) # Validate safe PDF output filename
@click.option( if output_filename:
"--set-container-runtime", valid = True
required=False, if not output_filename.endswith(".pdf"):
help=( click.echo("Safe PDF filename must end in '.pdf'")
"The name or full path of the container runtime you want Dangerzone to use." return
" You can specify the value 'default' if you want to take back your choice, and"
" let Dangerzone use the default runtime for this OS" try:
), with open(os.path.abspath(output_filename), "wb") as f:
) pass
@click.version_option(version=get_version(), message="%(version)s") except:
@errors.handle_document_errors valid = False
def cli_main(
output_filename: Optional[str], if not valid:
ocr_lang: Optional[str], click.echo("Safe PDF filename is not writable")
filenames: Optional[List[str]], return
archive: bool,
dummy_conversion: bool, common.output_filename = os.path.abspath(output_filename)
debug: bool,
set_container_runtime: Optional[str] = None, else:
) -> None: common.output_filename = (
setup_logging() f"{os.path.splitext(common.input_filename)[0]}-safe.pdf"
display_banner() )
if set_container_runtime: try:
settings = Settings() with open(common.output_filename, "wb") as f:
if set_container_runtime == "default": pass
settings.unset_custom_runtime() except:
click.echo( click.echo(
"Instructed Dangerzone to use the default container runtime for this OS" f"Output filename {common.output_filename} is not writable, use --output-filename"
) )
else: return
container_runtime = settings.set_custom_runtime(
set_container_runtime, autosave=True
)
click.echo(f"Set the settings container_runtime to {container_runtime}")
sys.exit(0)
elif not filenames:
raise click.UsageError("Missing argument 'FILENAMES...'")
if getattr(sys, "dangerzone_dev", False) and dummy_conversion:
dangerzone = DangerzoneCore(Dummy())
elif is_qubes_native_conversion():
dangerzone = DangerzoneCore(Qubes())
else:
dangerzone = DangerzoneCore(Container(debug=debug))
if len(filenames) == 1 and output_filename:
dangerzone.add_document_from_filename(filenames[0], output_filename, archive)
elif len(filenames) > 1 and output_filename:
click.echo("--output-filename can only be used with one input file.")
sys.exit(1)
else:
for filename in filenames:
dangerzone.add_document_from_filename(filename, archive=archive)
# Validate OCR language # Validate OCR language
if ocr_lang: if ocr_lang:
valid = False valid = False
for lang in dangerzone.ocr_languages: for lang in global_common.ocr_languages:
if dangerzone.ocr_languages[lang] == ocr_lang: if global_common.ocr_languages[lang] == ocr_lang:
valid = True valid = True
break break
if not valid: if not valid:
click.echo("Invalid OCR language code. Valid language codes:") click.echo("Invalid OCR language code. Valid language codes:")
for lang in dangerzone.ocr_languages: for lang in global_common.ocr_languages:
click.echo(f"{dangerzone.ocr_languages[lang]}: {lang}") click.echo(f"{global_common.ocr_languages[lang]}: {lang}")
sys.exit(1) return
# Ensure container is installed # Ensure container is installed
dangerzone.isolation_provider.install() global_common.install_container()
# Convert the document # Convert the document
print_header("Converting document to safe PDF") print_header("Converting document to safe PDF")
dangerzone.convert_documents(ocr_lang) def stdout_callback(line):
documents_safe = dangerzone.get_safe_documents() try:
documents_failed = dangerzone.get_failed_documents() status = json.loads(line)
s = Style.BRIGHT + Fore.CYAN + f"{status['percentage']}% "
if documents_safe != []: if status["error"]:
print_header("Safe PDF(s) created successfully") s += Style.RESET_ALL + Fore.RED + status["text"]
for document in documents_safe:
click.echo(replace_control_chars(document.output_filename))
if archive:
print_header(
f"Unsafe (original) documents moved to '{ARCHIVE_SUBDIR}' subdirectory"
)
if documents_failed != []:
print_header("Failed to convert document(s)")
for document in documents_failed:
click.echo(replace_control_chars(document.input_filename))
sys.exit(1)
else:
sys.exit(0)
args.override_parser_and_check_suspicious_options(cli_main)
def setup_logging() -> None:
class EndUserLoggingFormatter(logging.Formatter):
"""Prefixes any non-INFO log line with the log level"""
def format(self, record: logging.LogRecord) -> str:
if record.levelno == logging.INFO:
# Bypass formatter: print line directly
return record.getMessage()
else: else:
return super().format(record) s += Style.RESET_ALL + status["text"]
click.echo(s)
except:
click.echo(f"Invalid JSON returned from container: {line}")
if getattr(sys, "dangerzone_dev", False): if convert(
fmt = "[%(levelname)-5s] %(message)s" common.input_filename,
logging.basicConfig(level=logging.DEBUG, format=fmt) common.output_filename,
ocr_lang,
stdout_callback,
):
print_header("Safe PDF created successfully")
click.echo(common.output_filename)
sys.exit(0)
else: else:
# prefix non-INFO log lines with the respective log type print_header("Failed to convert document")
fmt = "%(levelname)s %(message)s" sys.exit(-1)
formatter = EndUserLoggingFormatter(fmt=fmt)
ch = logging.StreamHandler()
ch.setFormatter(formatter)
logger = logging.getLogger()
logger.setLevel(logging.INFO)
logger.addHandler(ch)
def display_banner() -> None:
"""
Raw ASCII art example:
Dangerzone v0.1.5
https://dangerzone.rocks
"""
print(Back.BLACK + Fore.YELLOW + Style.DIM + "╭──────────────────────────╮")
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ▄██▄ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ██████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███▀▀▀██ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███ ████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███ ██████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███ ▀▀▀▀████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███████ ▄██████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███████ ▄█████████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ████████████████████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(Back.BLACK + Fore.YELLOW + Style.DIM + "│ │")
left_spaces = (15 - len(get_version()) - 1) // 2
right_spaces = left_spaces
if left_spaces + len(get_version()) + 1 + right_spaces < 15:
right_spaces += 1
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Style.RESET_ALL
+ Back.BLACK
+ Fore.LIGHTWHITE_EX
+ Style.BRIGHT
+ f"{' ' * left_spaces}Dangerzone v{get_version()}{' ' * right_spaces}"
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Style.RESET_ALL
+ Back.BLACK
+ Fore.LIGHTWHITE_EX
+ " https://dangerzone.rocks "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ "╰──────────────────────────╯"
+ Style.RESET_ALL
)

16
dangerzone/common.py Normal file
View file

@ -0,0 +1,16 @@
import os
import stat
import platform
import tempfile
import appdirs
class Common(object):
"""
The Common class is a singleton of shared functionality throughout an open dangerzone window
"""
def __init__(self):
# Name of input and out files
self.input_filename = None
self.output_filename = None

212
dangerzone/container.py Normal file
View file

@ -0,0 +1,212 @@
import platform
import subprocess
import pipes
import shutil
import os
import tempfile
import appdirs
# What container tech is used for this platform?
if platform.system() == "Linux":
container_tech = "podman"
else:
# Windows, Darwin, and unknown use docker for now, dangerzone-vm eventually
container_tech = "docker"
# Define startupinfo for subprocesses
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
else:
startupinfo = None
# Name of the dangerzone container
container_name = "dangerzone.rocks/dangerzone"
def exec(args, stdout_callback=None):
args_str = " ".join(pipes.quote(s) for s in args)
print("> " + args_str)
with subprocess.Popen(
args,
stdin=None,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
universal_newlines=True,
startupinfo=startupinfo,
) as p:
if stdout_callback:
for line in p.stdout:
stdout_callback(line)
p.communicate()
return p.returncode
def exec_container(args, stdout_callback=None):
if container_tech == "podman":
container_runtime = shutil.which("podman")
else:
container_runtime = shutil.which("docker")
args = [container_runtime] + args
return exec(args, stdout_callback)
def convert(input_filename, output_filename, ocr_lang, stdout_callback):
success = False
if ocr_lang:
ocr = "1"
else:
ocr = "0"
dz_tmp = os.path.join(appdirs.user_config_dir("dangerzone"), "tmp")
os.makedirs(dz_tmp, exist_ok=True)
tmpdir = tempfile.TemporaryDirectory(dir=dz_tmp)
pixel_dir = os.path.join(tmpdir.name, "pixels")
safe_dir = os.path.join(tmpdir.name, "safe")
os.makedirs(pixel_dir, exist_ok=True)
os.makedirs(safe_dir, exist_ok=True)
if container_tech == "docker":
platform_args = ["--platform", "linux/amd64"]
else:
platform_args = []
# Convert document to pixels
args = (
["run", "--network", "none"]
+ platform_args
+ [
"-v",
f"{input_filename}:/tmp/input_file",
"-v",
f"{pixel_dir}:/dangerzone",
container_name,
"/usr/bin/python3",
"/usr/local/bin/dangerzone.py",
"document-to-pixels",
]
)
ret = exec_container(args, stdout_callback)
if ret != 0:
print("documents-to-pixels failed")
else:
# TODO: validate convert to pixels output
# Convert pixels to safe PDF
args = (
["run", "--network", "none"]
+ platform_args
+ [
"-v",
f"{pixel_dir}:/dangerzone",
"-v",
f"{safe_dir}:/safezone",
"-e",
f"OCR={ocr}",
"-e",
f"OCR_LANGUAGE={ocr_lang}",
container_name,
"/usr/bin/python3",
"/usr/local/bin/dangerzone.py",
"pixels-to-pdf",
]
)
ret = exec_container(args, stdout_callback)
if ret != 0:
print("pixels-to-pdf failed")
else:
# Move the final file to the right place
if os.path.exists(output_filename):
os.remove(output_filename)
container_output_filename = os.path.join(
safe_dir, "safe-output-compressed.pdf"
)
os.rename(container_output_filename, output_filename)
# We did it
success = True
# Clean up
shutil.rmtree(tmpdir.name)
return success
# From global_common:
# def validate_convert_to_pixel_output(self, common, output):
# """
# Take the output from the convert to pixels tasks and validate it. Returns
# a tuple like: (success (boolean), error_message (str))
# """
# max_image_width = 10000
# max_image_height = 10000
# # Did we hit an error?
# for line in output.split("\n"):
# if (
# "failed:" in line
# or "The document format is not supported" in line
# or "Error" in line
# ):
# return False, output
# # How many pages was that?
# num_pages = None
# for line in output.split("\n"):
# if line.startswith("Document has "):
# num_pages = line.split(" ")[2]
# break
# if not num_pages or not num_pages.isdigit() or int(num_pages) <= 0:
# return False, "Invalid number of pages returned"
# num_pages = int(num_pages)
# # Make sure we have the files we expect
# expected_filenames = []
# for i in range(1, num_pages + 1):
# expected_filenames += [
# f"page-{i}.rgb",
# f"page-{i}.width",
# f"page-{i}.height",
# ]
# expected_filenames.sort()
# actual_filenames = os.listdir(common.pixel_dir.name)
# actual_filenames.sort()
# if expected_filenames != actual_filenames:
# return (
# False,
# f"We expected these files:\n{expected_filenames}\n\nBut we got these files:\n{actual_filenames}",
# )
# # Make sure the files are the correct sizes
# for i in range(1, num_pages + 1):
# with open(f"{common.pixel_dir.name}/page-{i}.width") as f:
# w_str = f.read().strip()
# with open(f"{common.pixel_dir.name}/page-{i}.height") as f:
# h_str = f.read().strip()
# w = int(w_str)
# h = int(h_str)
# if (
# not w_str.isdigit()
# or not h_str.isdigit()
# or w <= 0
# or w > max_image_width
# or h <= 0
# or h > max_image_height
# ):
# return False, f"Page {i} has invalid geometry"
# # Make sure the RGB file is the correct size
# if os.path.getsize(f"{common.pixel_dir.name}/page-{i}.rgb") != w * h * 3:
# return False, f"Page {i} has an invalid RGB file size"
# return True, True

View file

@ -1,235 +0,0 @@
#!/usr/bin/python3
import json
import os
import shlex
import subprocess
import sys
import typing
# This script wraps the command-line arguments passed to it to run as an
# unprivileged user in a gVisor sandbox.
# Its behavior can be modified with the following environment variables:
# RUNSC_DEBUG: If set, print debug messages to stderr, and log all gVisor
# output to stderr.
# RUNSC_FLAGS: If set, pass these flags to the `runsc` invocation.
# These environment variables are not passed on to the sandboxed process.
def log(message: str, *values: typing.Any) -> None:
"""Helper function to log messages if RUNSC_DEBUG is set."""
if os.environ.get("RUNSC_DEBUG"):
print(message.format(*values), file=sys.stderr)
command = sys.argv[1:]
if len(command) == 0:
log("Invoked without a command; will execute 'sh'.")
command = ["sh"]
else:
log("Invoked with command: {}", " ".join(shlex.quote(s) for s in command))
# Build and write container OCI config.
oci_config: dict[str, typing.Any] = {
"ociVersion": "1.0.0",
"process": {
"user": {
# Hardcode the UID/GID of the container image to 1000, since we're in
# control of the image creation, and we don't expect it to change.
"uid": 1000,
"gid": 1000,
},
"args": command,
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"PYTHONPATH=/opt/dangerzone",
"TERM=xterm",
],
"cwd": "/",
"capabilities": {
"bounding": [],
"effective": [],
"inheritable": [],
"permitted": [],
},
"rlimits": [
{"type": "RLIMIT_NOFILE", "hard": 4096, "soft": 4096},
],
},
"root": {"path": "rootfs", "readonly": True},
"hostname": "dangerzone",
"mounts": [
# Mask almost every system directory of the outer container, by mounting tmpfs
# on top of them. This is done to avoid leaking any sensitive information,
# either mounted by Podman/Docker, or when gVisor runs, since we reuse the same
# rootfs. We basically mask everything except for `/usr`, `/bin`, `/lib`,
# `/etc`, and `/opt`.
#
# Note that we set `--root /home/dangerzone/.containers` for the directory where
# gVisor will create files at runtime, which means that in principle, we are
# covered by the masking of `/home/dangerzone` that follows below.
#
# Finally, note that the following list has been taken from the dirs in our
# container image, and double-checked against the top-level dirs listed in the
# Filesystem Hierarchy Standard (FHS) [1]. It would be nice to have an allowlist
# approach instead of a denylist, but FHS is such an old standard that we don't
# expect any new top-level dirs to pop up any time soon.
#
# [1] https://en.wikipedia.org/wiki/Filesystem_Hierarchy_Standard
{
"destination": "/boot",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev", "ro"],
},
{
"destination": "/dev",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev"],
},
{
"destination": "/home",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev", "ro"],
},
{
"destination": "/media",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev", "ro"],
},
{
"destination": "/mnt",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev", "ro"],
},
{
"destination": "/proc",
"type": "proc",
"source": "proc",
},
{
"destination": "/root",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev", "ro"],
},
{
"destination": "/run",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev"],
},
{
"destination": "/sbin",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev", "ro"],
},
{
"destination": "/srv",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev", "ro"],
},
{
"destination": "/sys",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev", "ro"],
},
{
"destination": "/tmp",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev"],
},
{
"destination": "/var",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev"],
},
# LibreOffice needs a writable home directory, so just mount a tmpfs
# over it.
{
"destination": "/home/dangerzone",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev"],
},
# Used for LibreOffice extensions, which are only conditionally
# installed depending on which file is being converted.
{
"destination": "/usr/lib/libreoffice/share/extensions/",
"type": "tmpfs",
"source": "tmpfs",
"options": ["nosuid", "noexec", "nodev"],
},
],
"linux": {
"namespaces": [
{"type": "pid"},
{"type": "network"},
{"type": "ipc"},
{"type": "uts"},
{"type": "mount"},
],
},
}
not_forwarded_env = set(
(
"PATH",
"HOME",
"SHLVL",
"HOSTNAME",
"TERM",
"PWD",
"RUNSC_FLAGS",
"RUNSC_DEBUG",
)
)
for key_val in oci_config["process"]["env"]:
not_forwarded_env.add(key_val[: key_val.index("=")])
for key, val in os.environ.items():
if key in not_forwarded_env:
continue
oci_config["process"]["env"].append("%s=%s" % (key, val))
if os.environ.get("RUNSC_DEBUG"):
log("Command inside gVisor sandbox: {}", command)
log("OCI config:")
json.dump(oci_config, sys.stderr, indent=2, sort_keys=True)
# json.dump doesn't print a trailing newline, so print one here:
log("")
with open("/home/dangerzone/dangerzone-image/config.json", "w") as oci_config_out:
json.dump(oci_config, oci_config_out, indent=2, sort_keys=True)
# Run gVisor.
runsc_argv = [
"/usr/bin/runsc",
"--rootless=true",
"--network=none",
"--root=/home/dangerzone/.containers",
# Disable DirectFS for to make the seccomp filter even stricter,
# at some performance cost.
"--directfs=false",
]
if os.environ.get("RUNSC_DEBUG"):
runsc_argv += ["--debug=true", "--alsologtostderr=true"]
if os.environ.get("RUNSC_FLAGS"):
runsc_argv += [x for x in shlex.split(os.environ.get("RUNSC_FLAGS", "")) if x]
runsc_argv += ["run", "--bundle=/home/dangerzone/dangerzone-image", "dangerzone"]
log(
"Running gVisor with command line: {}", " ".join(shlex.quote(s) for s in runsc_argv)
)
runsc_process = subprocess.run(
runsc_argv,
check=False,
)
log("gVisor quit with exit code: {}", runsc_process.returncode)
# We're done.
sys.exit(runsc_process.returncode)

View file

@ -1,29 +0,0 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBF0meAYBEACcBYPOSBiKtid+qTQlbgKGPxUYt0cNZiQqWXylhYUT4PuNlNx5
s+sBLFvNTpdTrXMmZ8NkekyjD1HardWvebvJT4u+Ho/9jUr4rP71cNwNtocz/w8G
DsUXSLgH8SDkq6xw0L+5eGc78BBg9cOeBeFBm3UPgxTBXS9Zevoi2w1lzSxkXvjx
cGzltzMZfPXERljgLzp9AAfhg/2ouqVQm37fY+P/NDzFMJ1XHPIIp9KJl/prBVud
jJJteFZ5sgL6MwjBQq2kw+q2Jb8Zfjl0BeXDgGMN5M5lGhX2wTfiMbfo7KWyzRnB
RpSP3BxlLqYeQUuLG5Yx8z3oA3uBkuKaFOKvXtiScxmGM/+Ri2YM3m66imwDhtmP
AKwTPI3Re4gWWOffglMVSv2sUAY32XZ74yXjY1VhK3bN3WFUPGrgQx4X7GP0A1Te
lzqkT3VSMXieImTASosK5L5Q8rryvgCeI9tQLn9EpYFCtU3LXvVgTreGNEEjMOnL
dR7yOU+Fs775stn6ucqmdYarx7CvKUrNAhgEeHMonLe1cjYScF7NfLO1GIrQKJR2
DE0f+uJZ52inOkO8ufh3WVQJSYszuS3HCY7w5oj1aP38k/y9zZdZvVvwAWZaiqBQ
iwjVs6Kub76VVZZhRDf4iYs8k1Zh64nXdfQt250d8U5yMPF3wIJ+c1yhxwARAQAB
tCpUaGUgZ1Zpc29yIEF1dGhvcnMgPGd2aXNvci1ib3RAZ29vZ2xlLmNvbT6JAk4E
EwEKADgCGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQRvHfheOnHCSRjnJ9Vv
xtVU4yvZQwUCYO4TxQAKCRBvxtVU4yvZQ9UoEACLPV7CnEA2bjCPi0NCWB/Mo1WL
evqv7Wv7vmXzI1K9DrqOhxuamQW75SVXg1df0hTJWbKFmDAip6NEC2Rg5P+A8hHj
nW/VG+q4ZFT662jDhnXQiO9L7EZzjyqNF4yWYzzgnqEu/SmGkDLDYiUCcGBqS2oE
EQfk7RHJSLMJXAnNDH7OUDgrirSssg/dlQ5uAHA9Au80VvC5fsTKza8b3Aydw3SV
iB8/Yuikbl8wKbpSGiXtR4viElXjNips0+mBqaUk2xpqSBrsfN+FezcInVXaXFeq
xtpq2/3M3DYbqCRjqeyd9wNi92FHdOusNrK4MYe0pAYbGjc65BwH+F0T4oJ8ZSJV
lIt+FZ0MqM1T97XadybYFsJh8qvajQpZEPL+zzNncc4f1d80e7+lwIZV/al0FZWW
Zlp7TpbeO/uW+lHs5W14YKwaQVh1whapKXTrATipNOOSCw2hnfrT8V7Hy55QWaGZ
f4/kfy929EeCP16d/LqOClv0j0RBr6NhRBQ0l/BE/mXjJwIk6nKwi+Yi4ek1ARi6
AlCMLn9AZF7aTGpvCiftzIrlyDfVZT5IX03TayxRHZ4b1Rj8eyJaHcjI49u83gkr
4LGX08lEawn9nxFSx4RCg2swGiYw5F436wwwAIozqJuDASeTa3QND3au5v0oYWnl
umDySUl5wPaAaALgzA==
=5/8T
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -1,103 +0,0 @@
#!/bin/bash
#
# Copyright The repro-sources-list.sh Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# repro-sources-list.sh:
# configures /etc/apt/sources.list and similar files for installing packages from a snapshot.
#
# This script is expected to be executed inside Dockerfile.
#
# The following distributions are supported:
# - debian:11 (/etc/apt/sources.list)
# - debian:12 (/etc/apt/sources.list.d/debian.sources)
# - ubuntu:22.04 (/etc/apt/sources.list)
# - ubuntu:24.04 (/etc/apt/sources.listd/ubuntu.sources)
# - archlinux (/etc/pacman.d/mirrorlist)
#
# For the further information, see https://github.com/reproducible-containers/repro-sources-list.sh
# -----------------------------------------------------------------------------
set -eux -o pipefail
. /etc/os-release
: "${KEEP_CACHE:=1}"
keep_apt_cache() {
rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache
}
case "${ID}" in
"debian")
: "${SNAPSHOT_ARCHIVE_BASE:=http://snapshot.debian.org/archive/}"
: "${BACKPORTS:=}"
if [ -e /etc/apt/sources.list.d/debian.sources ]; then
: "${SOURCE_DATE_EPOCH:=$(stat --format=%Y /etc/apt/sources.list.d/debian.sources)}"
rm -f /etc/apt/sources.list.d/debian.sources
else
: "${SOURCE_DATE_EPOCH:=$(stat --format=%Y /etc/apt/sources.list)}"
fi
snapshot="$(printf "%(%Y%m%dT%H%M%SZ)T\n" "${SOURCE_DATE_EPOCH}")"
# TODO: use the new format for Debian >= 12
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}debian/${snapshot} ${VERSION_CODENAME} main" >/etc/apt/sources.list
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}debian-security/${snapshot} ${VERSION_CODENAME}-security main" >>/etc/apt/sources.list
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}debian/${snapshot} ${VERSION_CODENAME}-updates main" >>/etc/apt/sources.list
if [ "${BACKPORTS}" = 1 ]; then echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}debian/${snapshot} ${VERSION_CODENAME}-backports main" >>/etc/apt/sources.list; fi
if [ "${KEEP_CACHE}" = 1 ]; then keep_apt_cache; fi
;;
"ubuntu")
: "${SNAPSHOT_ARCHIVE_BASE:=http://snapshot.ubuntu.com/}"
if [ -e /etc/apt/sources.list.d/ubuntu.sources ]; then
: "${SOURCE_DATE_EPOCH:=$(stat --format=%Y /etc/apt/sources.list.d/ubuntu.sources)}"
rm -f /etc/apt/sources.list.d/ubuntu.sources
else
: "${SOURCE_DATE_EPOCH:=$(stat --format=%Y /etc/apt/sources.list)}"
fi
snapshot="$(printf "%(%Y%m%dT%H%M%SZ)T\n" "${SOURCE_DATE_EPOCH}")"
# TODO: use the new format for Ubuntu >= 24.04
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}ubuntu/${snapshot} ${VERSION_CODENAME} main restricted" >/etc/apt/sources.list
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}ubuntu/${snapshot} ${VERSION_CODENAME}-updates main restricted" >>/etc/apt/sources.list
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}ubuntu/${snapshot} ${VERSION_CODENAME} universe" >>/etc/apt/sources.list
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}ubuntu/${snapshot} ${VERSION_CODENAME}-updates universe" >>/etc/apt/sources.list
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}ubuntu/${snapshot} ${VERSION_CODENAME} multiverse" >>/etc/apt/sources.list
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}ubuntu/${snapshot} ${VERSION_CODENAME}-updates multiverse" >>/etc/apt/sources.list
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}ubuntu/${snapshot} ${VERSION_CODENAME}-backports main restricted universe multiverse" >>/etc/apt/sources.list
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}ubuntu/${snapshot} ${VERSION_CODENAME}-security main restricted" >>/etc/apt/sources.list
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}ubuntu/${snapshot} ${VERSION_CODENAME}-security universe" >>/etc/apt/sources.list
echo "deb [check-valid-until=no] ${SNAPSHOT_ARCHIVE_BASE}ubuntu/${snapshot} ${VERSION_CODENAME}-security multiverse" >>/etc/apt/sources.list
if [ "${KEEP_CACHE}" = 1 ]; then keep_apt_cache; fi
# http://snapshot.ubuntu.com is redirected to https, so we have to install ca-certificates
export DEBIAN_FRONTEND=noninteractive
apt-get -o Acquire::https::Verify-Peer=false update >&2
apt-get -o Acquire::https::Verify-Peer=false install -y ca-certificates >&2
;;
"arch")
: "${SNAPSHOT_ARCHIVE_BASE:=http://archive.archlinux.org/}"
: "${SOURCE_DATE_EPOCH:=$(stat --format=%Y /var/log/pacman.log)}"
export SOURCE_DATE_EPOCH
# shellcheck disable=SC2016
date -d "@${SOURCE_DATE_EPOCH}" "+Server = ${SNAPSHOT_ARCHIVE_BASE}repos/%Y/%m/%d/\$repo/os/\$arch" >/etc/pacman.d/mirrorlist
;;
*)
echo >&2 "Unsupported distribution: ${ID}"
exit 1
;;
esac
: "${WRITE_SOURCE_DATE_EPOCH:=/dev/null}"
echo "${SOURCE_DATE_EPOCH}" >"${WRITE_SOURCE_DATE_EPOCH}"
echo "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}"

View file

@ -1,201 +0,0 @@
import logging
import os
import platform
import shutil
import subprocess
from pathlib import Path
from typing import List, Optional, Tuple
from . import errors
from .settings import Settings
from .util import get_resource_path, get_subprocess_startupinfo
CONTAINER_NAME = "dangerzone.rocks/dangerzone"
log = logging.getLogger(__name__)
class Runtime(object):
"""Represents the container runtime to use.
- It can be specified via the settings, using the "container_runtime" key,
which should point to the full path of the runtime;
- If the runtime is not specified via the settings, it defaults
to "podman" on Linux and "docker" on macOS and Windows.
"""
def __init__(self) -> None:
settings = Settings()
if settings.custom_runtime_specified():
self.path = Path(settings.get("container_runtime"))
if not self.path.exists():
raise errors.UnsupportedContainerRuntime(self.path)
self.name = self.path.stem
else:
self.name = self.get_default_runtime_name()
self.path = Runtime.path_from_name(self.name)
if self.name not in ("podman", "docker"):
raise errors.UnsupportedContainerRuntime(self.name)
@staticmethod
def path_from_name(name: str) -> Path:
name_path = Path(name)
if name_path.is_file():
return name_path
else:
runtime = shutil.which(name_path)
if runtime is None:
raise errors.NoContainerTechException(name)
return Path(runtime)
@staticmethod
def get_default_runtime_name() -> str:
return "podman" if platform.system() == "Linux" else "docker"
def get_runtime_version(runtime: Optional[Runtime] = None) -> Tuple[int, int]:
"""Get the major/minor parts of the Docker/Podman version.
Some of the operations we perform in this module rely on some Podman features
that are not available across all of our platforms. In order to have a proper
fallback, we need to know the Podman version. More specifically, we're fine with
just knowing the major and minor version, since writing/installing a full-blown
semver parser is an overkill.
"""
runtime = runtime or Runtime()
# Get the Docker/Podman version, using a Go template.
if runtime.name == "podman":
query = "{{.Client.Version}}"
else:
query = "{{.Server.Version}}"
cmd = [str(runtime.path), "version", "-f", query]
try:
version = subprocess.run(
cmd,
startupinfo=get_subprocess_startupinfo(),
capture_output=True,
check=True,
).stdout.decode()
except Exception as e:
msg = f"Could not get the version of the {runtime.name.capitalize()} tool: {e}"
raise RuntimeError(msg) from e
# Parse this version and return the major/minor parts, since we don't need the
# rest.
try:
major, minor, _ = version.split(".", 3)
return (int(major), int(minor))
except Exception as e:
msg = (
f"Could not parse the version of the {runtime.name.capitalize()} tool"
f" (found: '{version}') due to the following error: {e}"
)
raise RuntimeError(msg)
def list_image_tags() -> List[str]:
"""Get the tags of all loaded Dangerzone images.
This method returns a mapping of image tags to image IDs, for all Dangerzone
images. This can be useful when we want to find which are the local image tags,
and which image ID does the "latest" tag point to.
"""
runtime = Runtime()
return (
subprocess.check_output(
[
str(runtime.path),
"image",
"list",
"--format",
"{{ .Tag }}",
CONTAINER_NAME,
],
text=True,
startupinfo=get_subprocess_startupinfo(),
)
.strip()
.split()
)
def add_image_tag(image_id: str, new_tag: str) -> None:
"""Add a tag to the Dangerzone image."""
runtime = Runtime()
log.debug(f"Adding tag '{new_tag}' to image '{image_id}'")
subprocess.check_output(
[str(runtime.path), "tag", image_id, new_tag],
startupinfo=get_subprocess_startupinfo(),
)
def delete_image_tag(tag: str) -> None:
"""Delete a Dangerzone image tag."""
runtime = Runtime()
log.warning(f"Deleting old container image: {tag}")
try:
subprocess.check_output(
[str(runtime.name), "rmi", "--force", tag],
startupinfo=get_subprocess_startupinfo(),
)
except Exception as e:
log.warning(
f"Couldn't delete old container image '{tag}', so leaving it there."
f" Original error: {e}"
)
def get_expected_tag() -> str:
"""Get the tag of the Dangerzone image tarball from the image-id.txt file."""
with get_resource_path("image-id.txt").open() as f:
return f.read().strip()
def load_image_tarball() -> None:
runtime = Runtime()
log.info("Installing Dangerzone container image...")
tarball_path = get_resource_path("container.tar")
try:
res = subprocess.run(
[str(runtime.path), "load", "-i", str(tarball_path)],
startupinfo=get_subprocess_startupinfo(),
capture_output=True,
check=True,
)
except subprocess.CalledProcessError as e:
if e.stderr:
error = e.stderr.decode()
else:
error = "No output"
raise errors.ImageInstallationException(
f"Could not install container image: {error}"
)
# Loading an image built with Buildkit in Podman 3.4 messes up its name. The tag
# somehow becomes the name of the loaded image [1].
#
# We know that older Podman versions are not generally affected, since Podman v3.0.1
# on Debian Bullseye works properly. Also, Podman v4.0 is not affected, so it makes
# sense to target only Podman v3.4 for a fix.
#
# The fix is simple, tag the image properly based on the expected tag from
# `share/image-id.txt` and delete the incorrect tag.
#
# [1] https://github.com/containers/podman/issues/16490
if runtime.name == "podman" and get_runtime_version(runtime) == (3, 4):
expected_tag = get_expected_tag()
bad_tag = f"localhost/{expected_tag}:latest"
good_tag = f"{CONTAINER_NAME}:{expected_tag}"
log.debug(
f"Dangerzone images loaded in Podman v3.4 usually have an invalid tag."
" Fixing it..."
)
add_image_tag(bad_tag, good_tag)
delete_image_tag(bad_tag)
log.info("Successfully installed container image")

View file

@ -1,143 +0,0 @@
import asyncio
import os
import sys
from abc import abstractmethod
from typing import Callable, List, Optional, TextIO, Tuple
DEFAULT_DPI = 150 # Pixels per inch
INT_BYTES = 2
def running_on_qubes() -> bool:
# https://www.qubes-os.org/faq/#what-is-the-canonical-way-to-detect-qubes-vm
return os.path.exists("/usr/share/qubes/marker-vm")
class DangerzoneConverter:
def __init__(self, progress_callback: Optional[Callable] = None) -> None:
self.percentage: float = 0.0
self.progress_callback = progress_callback
self.captured_output: bytes = b""
@classmethod
def _read_bytes(cls) -> bytes:
"""Read bytes from the stdin."""
data = sys.stdin.buffer.read()
if data is None:
raise EOFError
return data
@classmethod
def _write_bytes(cls, data: bytes, file: TextIO = sys.stdout) -> None:
file.buffer.write(data)
@classmethod
def _write_text(cls, text: str, file: TextIO = sys.stdout) -> None:
cls._write_bytes(text.encode(), file=file)
@classmethod
def _write_int(cls, num: int, file: TextIO = sys.stdout) -> None:
cls._write_bytes(num.to_bytes(INT_BYTES, "big", signed=False), file=file)
# ==== ASYNC METHODS ====
# We run sync methods in async wrappers, because pure async methods are more difficult:
# https://stackoverflow.com/a/52702646
#
# In practice, because they are I/O bound and we don't have many running concurrently,
# they shouldn't cause a problem.
@classmethod
async def read_bytes(cls) -> bytes:
return await asyncio.to_thread(cls._read_bytes)
@classmethod
async def write_bytes(cls, data: bytes, file: TextIO = sys.stdout) -> None:
return await asyncio.to_thread(cls._write_bytes, data, file=file)
@classmethod
async def write_text(cls, text: str, file: TextIO = sys.stdout) -> None:
return await asyncio.to_thread(cls._write_text, text, file=file)
@classmethod
async def write_int(cls, num: int, file: TextIO = sys.stdout) -> None:
return await asyncio.to_thread(cls._write_int, num, file=file)
async def read_stream(
self, sr: asyncio.StreamReader, callback: Optional[Callable] = None
) -> bytes:
"""Consume a byte stream line-by-line.
Read all lines in a stream until EOF. If a user has passed a callback, call it for
each line.
Note that the lines are in bytes, since we can't assume that all command output will
be UTF-8 encoded. Higher level commands are advised to decode the output to Unicode,
if they know its encoding.
"""
buf = b""
while not sr.at_eof():
line = await sr.readline()
self.captured_output += line
if callback is not None:
await callback(line)
buf += line
return buf
async def run_command(
self,
args: List[str],
*,
error_message: str,
stdout_callback: Optional[Callable] = None,
stderr_callback: Optional[Callable] = None,
) -> Tuple[bytes, bytes]:
"""Run a command and get its output.
Run a command using asyncio.subprocess, consume its standard streams, and return its
output in bytes.
:raises RuntimeError: if the process returns a non-zero exit status
"""
# Start the provided command, and return a handle. The command will run in the
# background.
proc = await asyncio.subprocess.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# Log command to debug log so we can trace back which errors
# are from each command
self.captured_output += f"[COMMAND] {' '.join(args)}\n".encode()
assert proc.stdout is not None
assert proc.stderr is not None
# Create asynchronous tasks that will consume the standard streams of the command,
# and call callbacks if necessary.
stdout_task = asyncio.create_task(
self.read_stream(proc.stdout, stdout_callback)
)
stderr_task = asyncio.create_task(
self.read_stream(proc.stderr, stderr_callback)
)
# Wait until the command has finished. Then, verify that the command
# has completed successfully. In any other case, raise an exception.
ret = await proc.wait()
if ret != 0:
raise RuntimeError(error_message)
# Wait until the tasks that consume the command's standard streams have exited as
# well, and return their output.
stdout = await stdout_task
stderr = await stderr_task
return (stdout, stderr)
@abstractmethod
async def convert(self) -> None:
pass
@abstractmethod
def update_progress(self, text: str) -> None:
pass

View file

@ -1,302 +0,0 @@
import asyncio
import os
import sys
from typing import Dict, Optional
# XXX: PyMUPDF logs to stdout by default [1]. The PyMuPDF devs provide a way [2] to log to
# stderr, but it's based on environment variables. These envvars are consulted at import
# time [3], so we have to set them here, before we import `fitz`.
#
# [1] https://github.com/freedomofpress/dangerzone/issues/877
# [2] https://github.com/pymupdf/PyMuPDF/issues/3135#issuecomment-1992625724
# [3] https://github.com/pymupdf/PyMuPDF/blob/9717935eeb2d50d15440d62575878214226795f9/src/__init__.py#L62-L63
os.environ["PYMUPDF_MESSAGE"] = "fd:2"
os.environ["PYMUPDF_LOG"] = "fd:2"
import fitz
import magic
from . import errors
from .common import DEFAULT_DPI, DangerzoneConverter, running_on_qubes
class DocumentToPixels(DangerzoneConverter):
async def write_page_count(self, count: int) -> None:
return await self.write_int(count)
async def write_page_width(self, width: int) -> None:
return await self.write_int(width)
async def write_page_height(self, height: int) -> None:
return await self.write_int(height)
async def write_page_data(self, data: bytes) -> None:
return await self.write_bytes(data)
def update_progress(self, text: str, *, error: bool = False) -> None:
print(text, file=sys.stderr)
async def convert(self) -> None:
conversions: Dict[str, Dict[str, Optional[str]]] = {
# .pdf
"application/pdf": {"type": "PyMuPDF"},
# .docx
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
"type": "libreoffice",
},
# .doc
"application/msword": {
"type": "libreoffice",
},
# .docm
"application/vnd.ms-word.document.macroEnabled.12": {
"type": "libreoffice",
},
# .xlsx
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
"type": "libreoffice",
},
# .xls
"application/vnd.ms-excel": {
"type": "libreoffice",
},
# .pptx
"application/vnd.openxmlformats-officedocument.presentationml.presentation": {
"type": "libreoffice",
},
# .ppt
"application/vnd.ms-powerpoint": {
"type": "libreoffice",
},
# .odt
"application/vnd.oasis.opendocument.text": {
"type": "libreoffice",
},
# .odg
"application/vnd.oasis.opendocument.graphics": {
"type": "libreoffice",
},
# .odp
"application/vnd.oasis.opendocument.presentation": {
"type": "libreoffice",
},
# .ods
"application/vnd.oasis.opendocument.spreadsheet": {
"type": "libreoffice",
},
# .ods / .ots
"application/vnd.oasis.opendocument.spreadsheet-template": {
"type": "libreoffice",
},
# .odt / .ott
"application/vnd.oasis.opendocument.text-template": {
"type": "libreoffice",
},
# .hwp
# Commented MIMEs are not used in `file` and don't conform to the rules.
# Left them for just in case
# PR: https://github.com/freedomofpress/dangerzone/pull/460
# "application/haansofthwp": {
# "type": "libreoffice",
# "libreoffice_ext": "h2orestart.oxt",
# },
# "application/vnd.hancom.hwp": {
# "type": "libreoffice",
# "libreoffice_ext": "h2orestart.oxt",
# },
"application/x-hwp": {
"type": "libreoffice",
"libreoffice_ext": "h2orestart.oxt",
},
# .hwpx
# "application/haansofthwpx": {
# "type": "libreoffice",
# "libreoffice_ext": "h2orestart.oxt",
# },
# "application/vnd.hancom.hwpx": {
# "type": "libreoffice",
# "libreoffice_ext": "h2orestart.oxt",
# },
"application/x-hwp+zip": {
"type": "libreoffice",
"libreoffice_ext": "h2orestart.oxt",
},
"application/hwp+zip": {
"type": "libreoffice",
"libreoffice_ext": "h2orestart.oxt",
},
# At least .odt, .docx, .odg, .odp, .ods, and .pptx
"application/zip": {
"type": "libreoffice",
# NOTE: `file` command < 5.45 cannot detect hwpx files properly, so we
# enable the extension in any case. See also:
# https://github.com/freedomofpress/dangerzone/pull/460#issuecomment-1654166465
"libreoffice_ext": "h2orestart.oxt",
},
# At least .doc, .docx, .odg, .odp, .odt, .pdf, .ppt, .pptx, .xls, and .xlsx
"application/octet-stream": {
"type": "libreoffice",
},
# At least .doc, .ppt, and .xls
"application/x-ole-storage": {
"type": "libreoffice",
},
# .epub
"application/epub+zip": {"type": "PyMuPDF"},
# .svg
"image/svg+xml": {"type": "PyMuPDF"},
# .bmp
"image/bmp": {"type": "PyMuPDF"},
# .pnm
"image/x-portable-anymap": {"type": "PyMuPDF"},
# .pbm
"image/x-portable-bitmap": {"type": "PyMuPDF"},
# .ppm
"image/x-portable-pixmap": {"type": "PyMuPDF"},
# .jpg
"image/jpeg": {"type": "PyMuPDF"},
# .gif
"image/gif": {"type": "PyMuPDF"},
# .png
"image/png": {"type": "PyMuPDF"},
# .tif
"image/tiff": {"type": "PyMuPDF"},
"image/x-tiff": {"type": "PyMuPDF"},
}
# Detect MIME type
mime_type = self.detect_mime_type("/tmp/input_file")
# Validate MIME type
if mime_type not in conversions:
raise errors.DocFormatUnsupported()
# Temporary fix for the HWPX format
# Should be removed after new release of `file' (current release 5.44)
if mime_type == "application/zip":
file_type = self.detect_mime_type("/tmp/input_file")
hwpx_file_type = 'Zip data (MIME type "application/hwp+zip"?)'
if file_type == hwpx_file_type:
mime_type = "application/x-hwp+zip"
# Convert input document to PDF
conversion = conversions[mime_type]
if conversion["type"] == "PyMuPDF":
try:
doc = fitz.open("/tmp/input_file", filetype=mime_type)
except (ValueError, fitz.FileDataError):
raise errors.DocCorruptedException()
elif conversion["type"] == "libreoffice":
libreoffice_ext = conversion.get("libreoffice_ext", None)
# Disable conversion for HWP/HWPX on specific platforms. See:
#
# https://github.com/freedomofpress/dangerzone/issues/494
# https://github.com/freedomofpress/dangerzone/issues/498
if libreoffice_ext == "h2orestart.oxt" and running_on_qubes():
raise errors.DocFormatUnsupportedHWPQubes()
if libreoffice_ext:
await self.install_libreoffice_ext(libreoffice_ext)
self.update_progress("Converting to PDF using LibreOffice")
args = [
"libreoffice",
"--headless",
"--safe-mode",
"--convert-to",
"pdf",
"--outdir",
"/tmp",
"/tmp/input_file",
]
await self.run_command(
args,
error_message="Conversion to PDF with LibreOffice failed",
)
pdf_filename = "/tmp/input_file.pdf"
# XXX: Sometimes, LibreOffice can fail with status code 0. So, we need to
# always check if the file exists. See:
#
# https://github.com/freedomofpress/dangerzone/issues/494
if not os.path.exists(pdf_filename):
raise errors.LibreofficeFailure()
try:
doc = fitz.open(pdf_filename)
except (ValueError, fitz.FileDataError):
raise errors.DocCorruptedException()
else:
# NOTE: This should never be reached
raise errors.DocFormatUnsupported()
# Obtain number of pages
if doc.page_count > errors.MAX_PAGES:
raise errors.MaxPagesException()
await self.write_page_count(doc.page_count)
for page in doc.pages():
# TODO check if page.number is doc-controlled
page_num = page.number + 1 # pages start in 1
self.update_progress(
f"Converting page {page_num}/{doc.page_count} to pixels"
)
pix = page.get_pixmap(dpi=DEFAULT_DPI)
rgb_buf = pix.samples_mv
await self.write_page_width(pix.width)
await self.write_page_height(pix.height)
await self.write_page_data(rgb_buf)
self.update_progress("Converted document to pixels")
async def install_libreoffice_ext(self, libreoffice_ext: str) -> None:
self.update_progress(f"Installing LibreOffice extension '{libreoffice_ext}'")
unzip_args = [
"unzip",
"-d",
f"/usr/lib/libreoffice/share/extensions/{libreoffice_ext}/",
f"/opt/libreoffice_ext/{libreoffice_ext}",
]
await self.run_command(
unzip_args,
error_message="LibreOffice extension installation failed (unzipping)",
)
def detect_mime_type(self, path: str) -> str:
"""Detect MIME types in a platform-agnostic type.
Detect the MIME type of a file, either on Qubes or container platforms.
"""
try:
mime = magic.Magic(mime=True)
mime_type = mime.from_file("/tmp/input_file")
except TypeError:
mime_type = magic.detect_from_filename("/tmp/input_file").mime_type
return mime_type
async def main() -> None:
try:
data = await DocumentToPixels.read_bytes()
except EOFError:
sys.exit(1)
with open("/tmp/input_file", "wb") as f:
f.write(data)
try:
converter = DocumentToPixels()
await converter.convert()
except errors.ConversionException as e:
await DocumentToPixels.write_bytes(str(e).encode(), file=sys.stderr)
sys.exit(e.error_code)
except Exception as e:
await DocumentToPixels.write_bytes(str(e).encode(), file=sys.stderr)
error_code = errors.UnexpectedConversionError.error_code
sys.exit(error_code)
# Write debug information
await DocumentToPixels.write_bytes(converter.captured_output, file=sys.stderr)
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View file

@ -1,108 +0,0 @@
from typing import List, Optional, Type, Union
# XXX: errors start at 128 for conversion-related issues
ERROR_SHIFT = 128
MAX_PAGES = 10000
MAX_PAGE_WIDTH = 10000
MAX_PAGE_HEIGHT = 10000
class ConverterProcException(Exception):
"""Some exception occurred in the converter"""
def __init__(self) -> None:
super().__init__("The process spawned for the conversion has exited early")
class ConversionException(Exception):
error_message = "Unspecified error"
error_code = ERROR_SHIFT
def __init__(self, error_message: Optional[str] = None) -> None:
if error_message:
self.error_message = error_message
super().__init__(self.error_message)
@classmethod
def get_subclasses(cls) -> List[Type["ConversionException"]]:
subclasses = [cls]
for subclass in cls.__subclasses__():
subclasses += subclass.get_subclasses()
return subclasses
class QubesQrexecFailed(ConversionException):
error_code = 126 # No ERROR_SHIFT since this is a qrexec error
error_message = (
"Could not start a disposable qube for the file conversion. "
"More information should have shown up on the top-right corner of your screen."
)
class DocFormatUnsupported(ConversionException):
error_code = ERROR_SHIFT + 10
error_message = "The document format is not supported"
class DocFormatUnsupportedHWPQubes(DocFormatUnsupported):
error_code = ERROR_SHIFT + 16
error_message = "HWP / HWPX formats are not supported in Qubes"
class LibreofficeFailure(ConversionException):
error_code = ERROR_SHIFT + 20
error_message = "Conversion to PDF with LibreOffice failed"
class DocCorruptedException(ConversionException):
error_code = ERROR_SHIFT + 30
error_message = "The document appears to be corrupted and could not be opened"
class PagesException(ConversionException):
error_code = ERROR_SHIFT + 40
class NoPageCountException(PagesException):
error_code = ERROR_SHIFT + 41
error_message = "Number of pages could not be extracted from PDF"
class MaxPagesException(PagesException):
"""Max number of pages enforced by the client (to fail early) but also the
server, which distrusts the client"""
error_code = ERROR_SHIFT + 42
error_message = f"Number of pages exceeds maximum ({MAX_PAGES})"
class MaxPageWidthException(PagesException):
error_code = ERROR_SHIFT + 44
error_message = "A page exceeded the maximum width."
class MaxPageHeightException(PagesException):
error_code = ERROR_SHIFT + 45
error_message = "A page exceeded the maximum height."
class PageCountMismatch(PagesException):
error_code = ERROR_SHIFT + 46
error_message = (
"The final document does not have the same page count as the original one"
)
class UnexpectedConversionError(ConversionException):
error_code = ERROR_SHIFT + 100
error_message = "Some unexpected error occurred while converting the document"
def exception_from_error_code(
error_code: int,
) -> Union[ConversionException, ValueError]:
"""returns the conversion exception corresponding to the error code"""
for cls in ConversionException.get_subclasses():
if cls.error_code == error_code:
return cls()
return UnexpectedConversionError(f"Unknown error code '{error_code}'")

View file

@ -1,229 +0,0 @@
import enum
import logging
import os
import platform
import re
import secrets
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Optional
from . import errors, util
SAFE_EXTENSION = "-safe.pdf"
ARCHIVE_SUBDIR = "unsafe"
log = logging.getLogger(__name__)
class Document:
"""Track the state of a single document.
The Document class is responsible for holding the state of a single
document, and validating its info.
"""
# document conversion state
STATE_UNCONVERTED = enum.auto()
STATE_CONVERTING = enum.auto()
STATE_SAFE = enum.auto()
STATE_FAILED = enum.auto()
def __init__(
self,
input_filename: Optional[str] = None,
output_filename: Optional[str] = None,
suffix: str = SAFE_EXTENSION,
archive: bool = False,
) -> None:
# NOTE: See https://github.com/freedomofpress/dangerzone/pull/216#discussion_r1015449418
self.id = secrets.token_urlsafe(6)[0:6]
self._input_filename: Optional[str] = None
self._output_filename: Optional[str] = None
self._archive = False
self._suffix = suffix
if input_filename:
self.input_filename = input_filename
if output_filename:
self.output_filename = output_filename
self.state = Document.STATE_UNCONVERTED
self.archive_after_conversion = archive
@staticmethod
def normalize_filename(filename: str) -> str:
return os.path.abspath(filename)
@staticmethod
def validate_input_filename(filename: str) -> None:
try:
open(filename, "rb")
except FileNotFoundError as e:
raise errors.InputFileNotFoundException() from e
except PermissionError as e:
raise errors.InputFileNotReadableException() from e
@staticmethod
def validate_output_filename(filename: str) -> None:
if not filename.endswith(".pdf"):
raise errors.NonPDFOutputFileException()
if platform.system() == "Windows":
final_filename = PureWindowsPath(filename).name
illegal_chars_regex = re.compile(r"[\"*/:<>?\\|]")
else:
final_filename = PurePosixPath(filename).name
illegal_chars_regex = re.compile(r"[\\]")
if platform.system() in ("Windows", "Darwin"):
match = illegal_chars_regex.search(final_filename)
if match:
# filename contains illegal characters
raise errors.IllegalOutputFilenameException(match.group(0))
if not os.access(Path(filename).parent, os.W_OK):
# in unwriteable directory
raise errors.UnwriteableOutputDirException()
def validate_default_archive_dir(self) -> None:
"""Checks if archive dir can be created"""
if not os.access(self.default_archive_dir.parent, os.W_OK):
raise errors.UnwriteableArchiveDirException()
@property
def input_filename(self) -> str:
if self._input_filename is None:
raise errors.NotSetInputFilenameException()
else:
return self._input_filename
@input_filename.setter
def input_filename(self, filename: str) -> None:
filename = self.normalize_filename(filename)
self.validate_input_filename(filename)
self._input_filename = filename
self.announce_id()
@property
def output_filename(self) -> str:
if self._output_filename is None:
if self._input_filename is not None:
return self.default_output_filename
else:
raise errors.NotSetOutputFilenameException()
else:
return self._output_filename
@output_filename.setter
def output_filename(self, filename: str) -> None:
filename = self.normalize_filename(filename)
self.validate_output_filename(filename)
self._output_filename = filename
@property
def sanitized_output_filename(self) -> str:
return util.replace_control_chars(self.output_filename)
@property
def suffix(self) -> str:
return self._suffix
@suffix.setter
def suffix(self, suf: str) -> None:
if self._output_filename is None:
self._suffix = suf
else:
raise errors.SuffixNotApplicableException()
@property
def archive_after_conversion(self) -> bool:
return self._archive
@archive_after_conversion.setter
def archive_after_conversion(self, enabled: bool) -> None:
if enabled:
self.validate_default_archive_dir()
self._archive = True
else:
self._archive = False
def archive(self) -> None:
"""
Moves the original document to a subdirectory. Prevents the user from
mistakenly opening the unsafe (original) document.
"""
archive_dir = self.default_archive_dir
old_file_path = Path(self.input_filename)
new_file_path = archive_dir / old_file_path.name
log.debug(f"Archiving doc {self.id} to {new_file_path}")
Path.mkdir(archive_dir, exist_ok=True)
# On Windows, moving the file will fail if it already exists.
new_file_path.unlink(missing_ok=True)
old_file_path.rename(new_file_path)
@property
def default_archive_dir(self) -> Path:
return Path(self.input_filename).parent / ARCHIVE_SUBDIR
@property
def default_output_filename(self) -> str:
return f"{os.path.splitext(self.input_filename)[0]}{self.suffix}"
def announce_id(self) -> None:
sanitized_filename = util.replace_control_chars(self.input_filename)
log.info(f"Assigning ID '{self.id}' to doc '{sanitized_filename}'")
def set_output_dir(self, path: str) -> None:
# keep the same name
old_filename = os.path.basename(self.output_filename)
new_path = os.path.abspath(path)
if not os.path.exists(new_path):
raise errors.NonExistantOutputDirException()
if not os.path.isdir(new_path):
raise errors.OutputDirIsNotDirException()
if not os.access(new_path, os.W_OK):
raise errors.UnwriteableOutputDirException()
self._output_filename = os.path.join(new_path, old_filename)
def is_unconverted(self) -> bool:
return self.state is Document.STATE_UNCONVERTED
def is_converting(self) -> bool:
return self.state is Document.STATE_CONVERTING
def is_failed(self) -> bool:
return self.state is Document.STATE_FAILED
def is_safe(self) -> bool:
return self.state is Document.STATE_SAFE
def mark_as_converting(self) -> None:
log.debug(f"Marking doc {self.id} as 'converting'")
self.state = Document.STATE_CONVERTING
def mark_as_failed(self) -> None:
log.debug(f"Marking doc {self.id} as 'failed'")
self.state = Document.STATE_FAILED
def mark_as_safe(self) -> None:
log.debug(f"Marking doc {self.id} as 'safe'")
self.state = Document.STATE_SAFE
def __eq__(self, other: object) -> bool:
if not isinstance(other, Document):
return False
return (
Path(self.input_filename).absolute()
== Path(other.input_filename).absolute()
)
def __hash__(self) -> int:
return hash(str(Path(self.input_filename).absolute()))
def __str__(self) -> str:
return self.input_filename

View file

@ -1,146 +0,0 @@
import functools
import logging
import sys
from typing import Any, Callable, TypeVar, cast
import click
F = TypeVar("F", bound=Callable[..., Any])
log = logging.getLogger(__name__)
class DocumentFilenameException(Exception):
"""Exception for document-related filename errors."""
class AddedDuplicateDocumentException(DocumentFilenameException):
"""Exception for a document is added twice."""
def __init__(self) -> None:
super().__init__("A document was added twice")
class InputFileNotFoundException(DocumentFilenameException):
"""Exception for when an input file does not exist."""
def __init__(self) -> None:
super().__init__("Input file not found: make sure you typed it correctly.")
class InputFileNotReadableException(DocumentFilenameException):
"""Exception for when an input file exists but is not readable."""
def __init__(self) -> None:
super().__init__("You don't have permission to open the input file.")
class NonPDFOutputFileException(DocumentFilenameException):
"""Exception for when the output file is not a PDF."""
def __init__(self) -> None:
super().__init__("Safe PDF filename must end in '.pdf'")
class IllegalOutputFilenameException(DocumentFilenameException):
"""Exception for when the output file contains illegal characters."""
def __init__(self, char: str) -> None:
super().__init__(f"Illegal character: {char}")
class UnwriteableOutputDirException(DocumentFilenameException):
"""Exception for when the output file is not writeable."""
def __init__(self) -> None:
super().__init__("Safe PDF filename is not writable")
class NotSetInputFilenameException(DocumentFilenameException):
"""Exception for when the output filename is set before having an
associated input file."""
def __init__(self) -> None:
super().__init__("Input filename has not been set yet.")
class NotSetOutputFilenameException(DocumentFilenameException):
"""Exception for when the output filename is read before it has been set."""
def __init__(self) -> None:
super().__init__("Output filename has not been set yet.")
class NonExistantOutputDirException(DocumentFilenameException):
"""Exception for when the output dir does not exist."""
def __init__(self) -> None:
super().__init__("Output directory does not exist")
class OutputDirIsNotDirException(DocumentFilenameException):
"""Exception for when the specified output dir is not actually a dir."""
def __init__(self) -> None:
super().__init__("Specified output directory is actually not a directory")
class UnwriteableArchiveDirException(DocumentFilenameException):
"""Exception for when the archive directory cannot be created."""
def __init__(self) -> None:
super().__init__(
"Archive directory for storing unsafe documents cannot be created."
)
class SuffixNotApplicableException(DocumentFilenameException):
"""Exception for when the suffix cannot be applied to the output filename."""
def __init__(self) -> None:
super().__init__("Cannot set a suffix after setting an output filename")
def handle_document_errors(func: F) -> F:
"""Decorator to log document-related errors and exit gracefully."""
@functools.wraps(func)
def wrapper(*args, **kwargs): # type: ignore
try:
return func(*args, **kwargs)
except DocumentFilenameException as e:
if getattr(sys, "dangerzone_dev", False):
# Show the full traceback only on dev environments.
msg = "An exception occured while validating a document"
log.exception(msg)
click.echo(str(e))
sys.exit(1)
return cast(F, wrapper)
#### Container-related errors
class ImageNotPresentException(Exception):
pass
class ImageInstallationException(Exception):
pass
class NoContainerTechException(Exception):
def __init__(self, container_tech: str) -> None:
super().__init__(f"{container_tech} is not installed")
class NotAvailableContainerTechException(Exception):
def __init__(self, container_tech: str, error: str) -> None:
self.error = error
self.container_tech = container_tech
super().__init__(f"{container_tech} is not available")
class UnsupportedContainerRuntime(Exception):
pass

492
dangerzone/global_common.py Normal file
View file

@ -0,0 +1,492 @@
import sys
import os
import inspect
import appdirs
import platform
import subprocess
import shutil
import json
import gzip
import colorama
from colorama import Fore, Back, Style
from .settings import Settings
from .container import convert
class GlobalCommon(object):
"""
The GlobalCommon class is a singleton of shared functionality throughout the app
"""
def __init__(self):
# Version
try:
with open(self.get_resource_path("version.txt")) as f:
self.version = f.read().strip()
except FileNotFoundError:
# In dev mode, in Windows, get_resource_path doesn't work properly for the container, but luckily
# it doesn't need to know the version
self.version = "unknown"
# Initialize terminal colors
colorama.init(autoreset=True)
# App data folder
self.appdata_path = appdirs.user_config_dir("dangerzone")
# Container
self.container_name = "dangerzone.rocks/dangerzone"
# Languages supported by tesseract
self.ocr_languages = {
"Afrikaans": "ar",
"Albanian": "sqi",
"Amharic": "amh",
"Arabic": "ara",
"Arabic script": "Arabic",
"Armenian": "hye",
"Armenian script": "Armenian",
"Assamese": "asm",
"Azerbaijani": "aze",
"Azerbaijani (Cyrillic)": "aze_cyrl",
"Basque": "eus",
"Belarusian": "bel",
"Bengali": "ben",
"Bengali script": "Bengali",
"Bosnian": "bos",
"Breton": "bre",
"Bulgarian": "bul",
"Burmese": "mya",
"Canadian Aboriginal script": "Canadian_Aboriginal",
"Catalan": "cat",
"Cebuano": "ceb",
"Cherokee": "chr",
"Cherokee script": "Cherokee",
"Chinese - Simplified": "chi_sim",
"Chinese - Simplified (vertical)": "chi_sim_vert",
"Chinese - Traditional": "chi_tra",
"Chinese - Traditional (vertical)": "chi_tra_vert",
"Corsican": "cos",
"Croatian": "hrv",
"Cyrillic script": "Cyrillic",
"Czech": "ces",
"Danish": "dan",
"Devanagari script": "Devanagari",
"Divehi": "div",
"Dutch": "nld",
"Dzongkha": "dzo",
"English": "eng",
"English, Middle (1100-1500)": "enm",
"Esperanto": "epo",
"Estonian": "est",
"Ethiopic script": "Ethiopic",
"Faroese": "fao",
"Filipino": "fil",
"Finnish": "fin",
"Fraktur script": "Fraktur",
"Frankish": "frk",
"French": "fra",
"French, Middle (ca.1400-1600)": "frm",
"Frisian (Western)": "fry",
"Gaelic (Scots)": "gla",
"Galician": "glg",
"Georgian": "kat",
"Georgian script": "Georgian",
"German": "deu",
"Greek": "ell",
"Greek script": "Greek",
"Gujarati": "guj",
"Gujarati script": "Gujarati",
"Gurmukhi script": "Gurmukhi",
"Hangul script": "Hangul",
"Hangul (vertical) script": "Hangul_vert",
"Han - Simplified script": "HanS",
"Han - Simplified (vertical) script": "HanS_vert",
"Han - Traditional script": "HanT",
"Han - Traditional (vertical) script": "HanT_vert",
"Hatian": "hat",
"Hebrew": "heb",
"Hebrew script": "Hebrew",
"Hindi": "hin",
"Hungarian": "hun",
"Icelandic": "isl",
"Indonesian": "ind",
"Inuktitut": "iku",
"Irish": "gle",
"Italian": "ita",
"Italian - Old": "ita_old",
"Japanese": "jpn",
"Japanese script": "Japanese",
"Japanese (vertical)": "jpn_vert",
"Japanese (vertical) script": "Japanese_vert",
"Javanese": "jav",
"Kannada": "kan",
"Kannada script": "Kannada",
"Kazakh": "kaz",
"Khmer": "khm",
"Khmer script": "Khmer",
"Korean": "kor",
"Korean (vertical)": "kor_vert",
"Kurdish (Arabic)": "kur_ara",
"Kyrgyz": "kir",
"Lao": "lao",
"Lao script": "Lao",
"Latin": "lat",
"Latin script": "Latin",
"Latvian": "lav",
"Lithuanian": "lit",
"Luxembourgish": "ltz",
"Macedonian": "mkd",
"Malayalam": "mal",
"Malayalam script": "Malayalam",
"Malay": "msa",
"Maltese": "mlt",
"Maori": "mri",
"Marathi": "mar",
"Mongolian": "mon",
"Myanmar script": "Myanmar",
"Nepali": "nep",
"Norwegian": "nor",
"Occitan (post 1500)": "oci",
"Old Georgian": "kat_old",
"Oriya (Odia) script": "Oriya",
"Oriya": "ori",
"Pashto": "pus",
"Persian": "fas",
"Polish": "pol",
"Portuguese": "por",
"Punjabi": "pan",
"Quechua": "que",
"Romanian": "ron",
"Russian": "rus",
"Sanskrit": "san",
"script and orientation": "osd",
"Serbian (Latin)": "srp_latn",
"Serbian": "srp",
"Sindhi": "snd",
"Sinhala script": "Sinhala",
"Sinhala": "sin",
"Slovakian": "slk",
"Slovenian": "slv",
"Spanish, Castilian - Old": "spa_old",
"Spanish": "spa",
"Sundanese": "sun",
"Swahili": "swa",
"Swedish": "swe",
"Syriac script": "Syriac",
"Syriac": "syr",
"Tajik": "tgk",
"Tamil script": "Tamil",
"Tamil": "tam",
"Tatar": "tat",
"Telugu script": "Telugu",
"Telugu": "tel",
"Thaana script": "Thaana",
"Thai script": "Thai",
"Thai": "tha",
"Tibetan script": "Tibetan",
"Tibetan Standard": "bod",
"Tigrinya": "tir",
"Tonga": "ton",
"Turkish": "tur",
"Ukrainian": "ukr",
"Urdu": "urd",
"Uyghur": "uig",
"Uzbek (Cyrillic)": "uzb_cyrl",
"Uzbek": "uzb",
"Vietnamese script": "Vietnamese",
"Vietnamese": "vie",
"Welsh": "cym",
"Yiddish": "yid",
"Yoruba": "yor",
}
# Load settings
self.settings = Settings(self)
def display_banner(self):
"""
Raw ASCII art example:
Dangerzone v0.1.5
https://dangerzone.rocks
"""
print(Back.BLACK + Fore.YELLOW + Style.DIM + "╭──────────────────────────╮")
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ▄██▄ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ██████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███▀▀▀██ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███ ████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███ ██████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███ ▀▀▀▀████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███████ ▄██████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ███████ ▄█████████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ████████████████████ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Fore.LIGHTYELLOW_EX
+ Style.NORMAL
+ " ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(Back.BLACK + Fore.YELLOW + Style.DIM + "│ │")
left_spaces = (15 - len(self.version) - 1) // 2
right_spaces = left_spaces
if left_spaces + len(self.version) + 1 + right_spaces < 15:
right_spaces += 1
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Style.RESET_ALL
+ Back.BLACK
+ Fore.LIGHTWHITE_EX
+ Style.BRIGHT
+ f"{' '*left_spaces}Dangerzone v{self.version}{' '*right_spaces}"
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ ""
+ Style.RESET_ALL
+ Back.BLACK
+ Fore.LIGHTWHITE_EX
+ " https://dangerzone.rocks "
+ Fore.YELLOW
+ Style.DIM
+ ""
)
print(Back.BLACK + Fore.YELLOW + Style.DIM + "╰──────────────────────────╯")
def get_container_runtime(self):
if platform.system() == "Linux":
return shutil.which("podman")
else:
return shutil.which("docker")
def get_resource_path(self, filename):
if getattr(sys, "dangerzone_dev", False):
# Look for resources directory relative to python file
prefix = os.path.join(
os.path.dirname(
os.path.dirname(
os.path.abspath(inspect.getfile(inspect.currentframe()))
)
),
"share",
)
else:
if platform.system() == "Darwin":
prefix = os.path.join(
os.path.dirname(os.path.dirname(sys.executable)), "Resources/share"
)
elif platform.system() == "Linux":
prefix = os.path.join(sys.prefix, "share", "dangerzone")
else:
# Windows
prefix = os.path.join(os.path.dirname(sys.executable), "share")
resource_path = os.path.join(prefix, filename)
return resource_path
def get_subprocess_startupinfo(self):
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
return startupinfo
else:
return None
def install_container(self):
"""
Make sure the podman container is installed. Linux only.
"""
if self.is_container_installed():
return
# Load the container into podman
print("Installing Dangerzone container image...")
p = subprocess.Popen(
[self.get_container_runtime(), "load"],
stdin=subprocess.PIPE,
startupinfo=self.get_subprocess_startupinfo(),
)
chunk_size = 10240
compressed_container_path = self.get_resource_path("container.tar.gz")
with gzip.open(compressed_container_path) as f:
while True:
chunk = f.read(chunk_size)
if len(chunk) > 0:
p.stdin.write(chunk)
else:
break
p.communicate()
if not self.is_container_installed():
print("Failed to install the container image")
return False
print("Container image installed")
return True
def is_container_installed(self):
"""
See if the podman container is installed. Linux only.
"""
# Get the image id
with open(self.get_resource_path("image-id.txt")) as f:
expected_image_id = f.read().strip()
# See if this image is already installed
installed = False
found_image_id = subprocess.check_output(
[
self.get_container_runtime(),
"image",
"list",
"--format",
"{{.ID}}",
self.container_name,
],
text=True,
startupinfo=self.get_subprocess_startupinfo(),
)
found_image_id = found_image_id.strip()
if found_image_id == expected_image_id:
installed = True
elif found_image_id == "":
pass
else:
print(f"Deleting old dangerzone container image")
subprocess.check_output(
[self.get_container_runtime(), "rmi", "--force", found_image_id],
startupinfo=self.get_subprocess_startupinfo(),
)
return installed

View file

@ -1,83 +1,37 @@
import enum
import logging
import os import os
import platform
import signal
import sys import sys
import typing import signal
from typing import List, Optional import platform
import click import click
import colorama import uuid
from PySide2 import QtCore, QtWidgets
# FIXME: See https://github.com/freedomofpress/dangerzone/issues/320 for more details. from .common import GuiCommon
if typing.TYPE_CHECKING:
from PySide2 import QtCore, QtGui, QtWidgets
else:
try:
from PySide6 import QtCore, QtGui, QtWidgets
except ImportError:
from PySide2 import QtCore, QtGui, QtWidgets
from .. import args, errors
from ..document import Document
from ..isolation_provider.container import Container
from ..isolation_provider.dummy import Dummy
from ..isolation_provider.qubes import Qubes, is_qubes_native_conversion
from ..util import get_resource_path, get_version
from .logic import DangerzoneGui
from .main_window import MainWindow from .main_window import MainWindow
from .updater import UpdaterThread from .systray import SysTray
from ..global_common import GlobalCommon
log = logging.getLogger(__name__)
class OSColorMode(enum.Enum): # For some reason, Dangerzone segfaults if I inherit from QApplication directly, so instead
""" # this is a class whose job is to hold a QApplication object and customize it
Operating system color mode, e.g. Light or Dark Mode on macOS 10.14+ or Windows 10+. class ApplicationWrapper(QtCore.QObject):
document_selected = QtCore.Signal(str)
The enum values are used as the names of Qt properties that will be selected by QSS new_window = QtCore.Signal()
property selectors to set color-mode-specific style rules.
"""
LIGHT = "light"
DARK = "dark"
class Application(QtWidgets.QApplication):
document_selected = QtCore.Signal(list)
application_activated = QtCore.Signal() application_activated = QtCore.Signal()
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: def __init__(self):
super(Application, self).__init__(*args, **kwargs) super(ApplicationWrapper, self).__init__()
self.setQuitOnLastWindowClosed(False) self.app = QtWidgets.QApplication()
with get_resource_path("dangerzone.css").open("r") as f: self.app.setQuitOnLastWindowClosed(False)
style = f.read()
self.setStyleSheet(style)
# Needed under certain windowing systems to match the application to the self.original_event = self.app.event
# desktop entry in order to display the correct application name and icon
# and to allow identifying windows that belong to the application (e.g.
# under Wayland it sets the correct app ID). The value is the name of the
# Dangerzone .desktop file.
self.setDesktopFileName("press.freedom.dangerzone")
# In some combinations of window managers and OSes, if we don't set an def monkeypatch_event(event):
# application name, then the window manager may report it as `python3` or
# `__init__.py`. Always set this to `dangerzone`, which corresponds to the
# executable name as well.
# See: https://github.com/freedomofpress/dangerzone/issues/402
self.setApplicationName("dangerzone")
self.original_event = self.event
def monkeypatch_event(arg__1: QtCore.QEvent) -> bool:
event = arg__1 # oddly Qt calls internally event by "arg__1"
# In macOS, handle the file open event # In macOS, handle the file open event
if isinstance(event, QtGui.QFileOpenEvent): if event.type() == QtCore.QEvent.FileOpen:
# Skip file open events in dev mode # Skip file open events in dev mode
if not hasattr(sys, "dangerzone_dev"): if not sys.dangerzone_dev:
self.document_selected.emit([event.file()]) self.document_selected.emit(event.file())
return True return True
elif event.type() == QtCore.QEvent.ApplicationActivate: elif event.type() == QtCore.QEvent.ApplicationActivate:
self.application_activated.emit() self.application_activated.emit()
@ -85,42 +39,12 @@ class Application(QtWidgets.QApplication):
return self.original_event(event) return self.original_event(event)
self.event = monkeypatch_event # type: ignore [method-assign] self.app.event = monkeypatch_event
self.os_color_mode = self.infer_os_color_mode()
log.debug(f"Inferred system color scheme as {self.os_color_mode}")
def infer_os_color_mode(self) -> OSColorMode:
"""
Qt 6.5+ explicitly provides the OS color scheme via QStyleHints.colorScheme(),
but we still need to support PySide2/Qt 5, so instead we infer the OS color
scheme from the default palette.
"""
text_color, window_color = (
self.palette().color(role)
for role in (QtGui.QPalette.WindowText, QtGui.QPalette.Window)
)
if text_color.lightness() > window_color.lightness():
return OSColorMode.DARK
return OSColorMode.LIGHT
@click.command() @click.command()
@click.option( @click.argument("filename", required=False)
"--unsafe-dummy-conversion", "dummy_conversion", flag_value=True, hidden=True def gui_main(filename):
)
@click.argument(
"filenames",
required=False,
nargs=-1,
type=click.UNPROCESSED,
callback=args.validate_input_filenames,
)
@click.version_option(version=get_version(), message="%(version)s")
@errors.handle_document_errors
def gui_main(dummy_conversion: bool, filenames: Optional[List[str]]) -> bool:
setup_logging()
if platform.system() == "Darwin": if platform.system() == "Darwin":
# Required for macOS Big Sur: https://stackoverflow.com/a/64878899 # Required for macOS Big Sur: https://stackoverflow.com/a/64878899
os.environ["QT_MAC_WANTS_LAYER"] = "1" os.environ["QT_MAC_WANTS_LAYER"] = "1"
@ -128,70 +52,97 @@ def gui_main(dummy_conversion: bool, filenames: Optional[List[str]]) -> bool:
# Make sure /usr/local/bin is in the path # Make sure /usr/local/bin is in the path
os.environ["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin" os.environ["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin"
# Don't show ANSI colors from stdout output, to prevent terminal # Strip ANSI colors from stdout output, to prevent terminal colors from breaking
# colors from breaking the macOS GUI app # the macOS GUI app
colorama.deinit() from strip_ansi import strip_ansi
class StdoutFilter:
def __init__(self, stream):
self.stream = stream
def __getattr__(self, attr_name):
return getattr(self.stream, attr_name)
def write(self, data):
self.stream.write(strip_ansi(data))
def flush(self):
self.stream.flush()
sys.stdout = StdoutFilter(sys.stdout)
sys.stderr = StdoutFilter(sys.stderr)
# Create the Qt app # Create the Qt app
app = Application() app_wrapper = ApplicationWrapper()
app = app_wrapper.app
# Common objects # Common objects
if getattr(sys, "dangerzone_dev", False) and dummy_conversion: global_common = GlobalCommon()
dummy = Dummy() gui_common = GuiCommon(app, global_common)
dangerzone = DangerzoneGui(app, isolation_provider=dummy)
elif is_qubes_native_conversion():
qubes = Qubes()
dangerzone = DangerzoneGui(app, isolation_provider=qubes)
else:
container = Container()
dangerzone = DangerzoneGui(app, isolation_provider=container)
# Allow Ctrl-C to smoothly quit the program instead of throwing an exception # Allow Ctrl-C to smoothly quit the program instead of throwing an exception
signal.signal(signal.SIGINT, signal.SIG_DFL) signal.signal(signal.SIGINT, signal.SIG_DFL)
def open_files(filenames: List[str] = []) -> None: # Create the system tray
documents = [Document(filename) for filename in filenames] systray = SysTray(global_common, gui_common, app, app_wrapper)
window.content_widget.doc_selection_widget.documents_selected.emit(documents)
window = MainWindow(dangerzone) closed_windows = {}
windows = {}
# Check for updates def delete_window(window_id):
log.debug("Setting up Dangerzone updater") closed_windows[window_id] = windows[window_id]
updater = UpdaterThread(dangerzone) del windows[window_id]
window.register_update_handler(updater.finished)
log.debug("Consulting updater settings before checking for updates") # Open a document in a window
if updater.should_check_for_updates(): def select_document(filename=None):
log.debug("Checking for updates") if (
updater.start() len(windows) == 1
and windows[list(windows.keys())[0]].common.input_filename == None
):
window = windows[list(windows.keys())[0]]
else:
window_id = uuid.uuid4().hex
window = MainWindow(global_common, gui_common, window_id)
window.delete_window.connect(delete_window)
windows[window_id] = window
if filename:
# Validate filename
filename = os.path.abspath(os.path.expanduser(filename))
try:
open(filename, "rb")
except FileNotFoundError:
click.echo("File not found")
return False
except PermissionError:
click.echo("Permission denied")
return False
window.common.input_filename = filename
window.content_widget.doc_selection_widget.document_selected.emit()
return True
# Open a new window if not filename is passed
if filename is None:
select_document()
else: else:
log.debug("Will not check for updates, based on updater settings") # If filename is passed as an argument, open it
if not select_document(filename):
return True
# Ensure the status of the toggle updates checkbox is updated, after the user is # Open a new window, if all windows are closed
# prompted to enable updates. def application_activated():
window.toggle_updates_action.setChecked(bool(updater.check)) if len(windows) == 0:
select_document()
if filenames:
open_files(filenames)
# MacOS: Open a new window, if all windows are closed
def application_activated() -> None:
window.show()
# If we get a file open event, open it # If we get a file open event, open it
app.document_selected.connect(open_files) app_wrapper.document_selected.connect(select_document)
app_wrapper.new_window.connect(select_document)
# If the application is activated and all windows are closed, open a new one # If the application is activated and all windows are closed, open a new one
app.application_activated.connect(application_activated) app_wrapper.application_activated.connect(application_activated)
# Launch the GUI # Launch the GUI
ret = app.exec_() ret = app.exec_()
sys.exit(ret) sys.exit(ret)
def setup_logging() -> None:
logging.basicConfig(level=logging.DEBUG, format="[%(levelname)s] %(message)s")
args.override_parser_and_check_suspicious_options(gui_main)

176
dangerzone/gui/common.py Normal file
View file

@ -0,0 +1,176 @@
import os
import platform
import subprocess
import shlex
import pipes
from PySide2 import QtCore, QtGui, QtWidgets
from colorama import Fore
if platform.system() == "Darwin":
import plistlib
elif platform.system() == "Linux":
import grp
import getpass
from xdg.DesktopEntry import DesktopEntry
from ..settings import Settings
class GuiCommon(object):
"""
The GuiCommon class is a singleton of shared functionality for the GUI
"""
def __init__(self, app, global_common):
# Qt app
self.app = app
# Global common singleton
self.global_common = global_common
# Preload font
self.fixed_font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont)
# Preload list of PDF viewers on computer
self.pdf_viewers = self._find_pdf_viewers()
# Are we done waiting (for Docker Desktop to be installed, or for container to install)
self.is_waiting_finished = False
def get_window_icon(self):
if platform.system() == "Windows":
path = self.global_common.get_resource_path("dangerzone.ico")
else:
path = self.global_common.get_resource_path("icon.png")
return QtGui.QIcon(path)
def open_pdf_viewer(self, filename):
if platform.system() == "Darwin":
# Open in Preview
args = ["open", "-a", "Preview.app", filename]
# Run
args_str = " ".join(pipes.quote(s) for s in args)
print(Fore.YELLOW + "> " + Fore.CYAN + args_str)
subprocess.run(args)
elif platform.system() == "Linux":
# Get the PDF reader command
args = shlex.split(
self.pdf_viewers[self.global_common.settings.get("open_app")]
)
# %f, %F, %u, and %U are filenames or URLS -- so replace with the file to open
for i in range(len(args)):
if (
args[i] == "%f"
or args[i] == "%F"
or args[i] == "%u"
or args[i] == "%U"
):
args[i] = filename
# Open as a background process
args_str = " ".join(pipes.quote(s) for s in args)
print(Fore.YELLOW + "> " + Fore.CYAN + args_str)
subprocess.Popen(args)
def _find_pdf_viewers(self):
pdf_viewers = {}
if platform.system() == "Linux":
# Find all .desktop files
for search_path in [
"/usr/share/applications",
"/usr/local/share/applications",
os.path.expanduser("~/.local/share/applications"),
]:
try:
for filename in os.listdir(search_path):
full_filename = os.path.join(search_path, filename)
if os.path.splitext(filename)[1] == ".desktop":
# See which ones can open PDFs
desktop_entry = DesktopEntry(full_filename)
if (
"application/pdf" in desktop_entry.getMimeTypes()
and desktop_entry.getName() != "dangerzone"
):
pdf_viewers[
desktop_entry.getName()
] = desktop_entry.getExec()
except FileNotFoundError:
pass
return pdf_viewers
class Alert(QtWidgets.QDialog):
def __init__(
self, gui_common, global_common, message, ok_text="Ok", extra_button_text=None
):
super(Alert, self).__init__()
self.global_common = global_common
self.gui_common = gui_common
self.setWindowTitle("dangerzone")
self.setWindowIcon(self.gui_common.get_window_icon())
self.setModal(True)
flags = (
QtCore.Qt.CustomizeWindowHint
| QtCore.Qt.WindowTitleHint
| QtCore.Qt.WindowSystemMenuHint
| QtCore.Qt.WindowCloseButtonHint
| QtCore.Qt.WindowStaysOnTopHint
)
self.setWindowFlags(flags)
logo = QtWidgets.QLabel()
logo.setPixmap(
QtGui.QPixmap.fromImage(
QtGui.QImage(self.global_common.get_resource_path("icon.png"))
)
)
label = QtWidgets.QLabel()
label.setText(message)
label.setWordWrap(True)
message_layout = QtWidgets.QHBoxLayout()
message_layout.addWidget(logo)
message_layout.addSpacing(10)
message_layout.addWidget(label, stretch=1)
ok_button = QtWidgets.QPushButton(ok_text)
ok_button.clicked.connect(self.clicked_ok)
if extra_button_text:
extra_button = QtWidgets.QPushButton(extra_button_text)
extra_button.clicked.connect(self.clicked_extra)
cancel_button = QtWidgets.QPushButton("Cancel")
cancel_button.clicked.connect(self.clicked_cancel)
buttons_layout = QtWidgets.QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(ok_button)
if extra_button_text:
buttons_layout.addWidget(extra_button)
buttons_layout.addWidget(cancel_button)
layout = QtWidgets.QVBoxLayout()
layout.addLayout(message_layout)
layout.addSpacing(10)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def clicked_ok(self):
self.done(QtWidgets.QDialog.Accepted)
def clicked_extra(self):
self.done(2)
def clicked_cancel(self):
self.done(QtWidgets.QDialog.Rejected)
def launch(self):
return self.exec_()

View file

@ -1,401 +0,0 @@
from __future__ import annotations
import logging
import os
import platform
import shlex
import subprocess
import typing
from collections import OrderedDict
from pathlib import Path
from typing import Optional
from colorama import Fore
# FIXME: See https://github.com/freedomofpress/dangerzone/issues/320 for more details.
if typing.TYPE_CHECKING:
from PySide2 import QtCore, QtGui, QtWidgets
from . import Application
else:
try:
from PySide6 import QtCore, QtGui, QtWidgets
except ImportError:
from PySide2 import QtCore, QtGui, QtWidgets
if platform.system() == "Linux":
from xdg.DesktopEntry import DesktopEntry, ParsingError
from ..isolation_provider.base import IsolationProvider
from ..logic import DangerzoneCore
from ..util import get_resource_path, replace_control_chars
log = logging.getLogger(__name__)
class DangerzoneGui(DangerzoneCore):
"""
Singleton of shared state / functionality for the GUI and core app logic
"""
def __init__(
self, app: "Application", isolation_provider: IsolationProvider
) -> None:
super().__init__(isolation_provider)
# Qt app
self.app = app
# Only one output dir is supported in the GUI
self.output_dir: str = ""
# Preload font
self.fixed_font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont)
# Preload ordered list of PDF viewers on computer, starting with default
self.pdf_viewers = self._find_pdf_viewers()
# Are we done waiting (for Docker Desktop to be installed, or for container to install)
self.is_waiting_finished = False
def get_window_icon(self) -> QtGui.QIcon:
if platform.system() == "Windows":
path = get_resource_path("dangerzone.ico")
else:
path = get_resource_path("icon.png")
return QtGui.QIcon(str(path))
def open_pdf_viewer(self, filename: str) -> None:
if platform.system() == "Darwin":
# Open in Preview
args = ["open", "-a", "Preview.app", filename]
# Run
args_str = replace_control_chars(" ".join(shlex.quote(s) for s in args))
log.info(Fore.YELLOW + "> " + Fore.CYAN + args_str)
subprocess.run(args)
elif platform.system() == "Windows":
os.startfile(Path(filename)) # type: ignore [attr-defined]
elif platform.system() == "Linux":
# Get the PDF reader command
args = shlex.split(self.pdf_viewers[self.settings.get("open_app")])
# %f, %F, %u, and %U are filenames or URLS -- so replace with the file to open
for i in range(len(args)):
if (
args[i] == "%f"
or args[i] == "%F"
or args[i] == "%u"
or args[i] == "%U"
):
args[i] = filename
# Open as a background process
args_str = replace_control_chars(" ".join(shlex.quote(s) for s in args))
log.info(Fore.YELLOW + "> " + Fore.CYAN + args_str)
subprocess.Popen(args)
def _find_pdf_viewers(self) -> OrderedDict[str, str]:
pdf_viewers: OrderedDict[str, str] = OrderedDict()
if platform.system() == "Linux":
# Opportunistically query for default pdf handler
default_pdf_viewer = None
try:
default_pdf_viewer = subprocess.check_output(
["xdg-mime", "query", "default", "application/pdf"]
).decode()
except (FileNotFoundError, subprocess.CalledProcessError) as e:
# Log it and continue
log.info(
"xdg-mime query failed, default PDF handler could not be found."
)
log.debug(f"xdg-mime query failed: {e}")
# Find all .desktop files
for search_path in [
"/usr/share/applications",
"/usr/local/share/applications",
os.path.expanduser("~/.local/share/applications"),
]:
try:
for filename in os.listdir(search_path):
full_filename = os.path.join(search_path, filename)
if os.path.splitext(filename)[1] == ".desktop":
# See which ones can open PDFs
try:
desktop_entry = DesktopEntry(full_filename)
except ParsingError:
# Do not stop when encountering malformed desktop entries
continue
except Exception:
log.exception(
"Encountered the following exception while processing desktop entry %s",
full_filename,
)
else:
desktop_entry_name = desktop_entry.getName()
if (
"application/pdf" in desktop_entry.getMimeTypes()
and "dangerzone" not in desktop_entry_name.lower()
):
pdf_viewers[desktop_entry_name] = (
desktop_entry.getExec()
)
# Put the default entry first
if filename == default_pdf_viewer:
try:
pdf_viewers.move_to_end(
desktop_entry_name, last=False
)
except KeyError as e:
# Should be unreachable
log.error(
f"Problem reordering applications: {e}"
)
except FileNotFoundError:
pass
return pdf_viewers
class Dialog(QtWidgets.QDialog):
def __init__(
self,
dangerzone: DangerzoneGui,
title: str,
ok_text: str = "Ok",
has_cancel: bool = True,
cancel_text: str = "Cancel",
extra_button_text: Optional[str] = None,
) -> None:
super().__init__()
self.dangerzone = dangerzone
self.setProperty("OSColorMode", self.dangerzone.app.os_color_mode.value)
self.setWindowTitle(title)
self.setWindowIcon(self.dangerzone.get_window_icon())
self.setModal(True)
flags = (
QtCore.Qt.CustomizeWindowHint
| QtCore.Qt.WindowTitleHint
| QtCore.Qt.WindowSystemMenuHint
| QtCore.Qt.WindowCloseButtonHint
| QtCore.Qt.WindowStaysOnTopHint
)
self.setWindowFlags(flags)
message_layout = self.create_layout()
self.ok_button = QtWidgets.QPushButton(ok_text)
self.ok_button.clicked.connect(self.clicked_ok)
self.extra_button: Optional[QtWidgets.QPushButton] = None
if extra_button_text:
self.extra_button = QtWidgets.QPushButton(extra_button_text)
self.extra_button.clicked.connect(self.clicked_extra)
self.cancel_button: Optional[QtWidgets.QPushButton] = None
if has_cancel:
self.cancel_button = QtWidgets.QPushButton(cancel_text)
self.cancel_button.clicked.connect(self.clicked_cancel)
buttons_layout = self.create_buttons_layout()
layout = QtWidgets.QVBoxLayout()
layout.addLayout(message_layout)
layout.addSpacing(10)
layout.addLayout(buttons_layout)
self.setLayout(layout)
def create_buttons_layout(self) -> QtWidgets.QHBoxLayout:
buttons_layout = QtWidgets.QHBoxLayout()
buttons_layout.addStretch()
buttons_layout.addWidget(self.ok_button)
if self.extra_button:
buttons_layout.addWidget(self.extra_button)
if self.cancel_button:
buttons_layout.addWidget(self.cancel_button)
return buttons_layout
def create_layout(self) -> QtWidgets.QBoxLayout:
raise NotImplementedError("Dangerzone dialogs must implement this method")
def clicked_ok(self) -> None:
self.done(int(QtWidgets.QDialog.Accepted))
def clicked_extra(self) -> None:
self.done(2)
def clicked_cancel(self) -> None:
self.done(int(QtWidgets.QDialog.Rejected))
def launch(self) -> int:
return self.exec()
class Alert(Dialog):
def __init__( # type: ignore [no-untyped-def]
self,
*args,
message: str = "",
**kwargs,
) -> None:
self.message = message
kwargs.setdefault("title", "dangerzone")
super().__init__(*args, **kwargs)
def create_layout(self) -> QtWidgets.QBoxLayout:
logo = QtWidgets.QLabel()
logo.setPixmap(
QtGui.QPixmap.fromImage(QtGui.QImage(str(get_resource_path("icon.png"))))
)
label = QtWidgets.QLabel()
label.setText(self.message)
label.setWordWrap(True)
label.setOpenExternalLinks(True)
message_layout = QtWidgets.QHBoxLayout()
message_layout.addWidget(logo)
message_layout.addSpacing(10)
message_layout.addWidget(label, stretch=1)
return message_layout
class UpdateDialog(Dialog):
def __init__( # type: ignore [no-untyped-def]
self,
*args,
intro_msg: Optional[str] = None,
middle_widget: Optional[QtWidgets.QWidget] = None,
epilogue_msg: Optional[str] = None,
**kwargs,
) -> None:
self.intro_msg = intro_msg
self.middle_widget = middle_widget
self.epilogue_msg = epilogue_msg
super().__init__(*args, **kwargs)
def create_layout(self) -> QtWidgets.QBoxLayout:
self.setMinimumWidth(500)
message_layout = QtWidgets.QVBoxLayout()
if self.intro_msg is not None:
intro = QtWidgets.QLabel()
intro.setText(self.intro_msg)
intro.setWordWrap(True)
intro.setAlignment(QtCore.Qt.AlignCenter)
intro.setOpenExternalLinks(True)
message_layout.addWidget(intro)
message_layout.addSpacing(10)
if self.middle_widget is not None:
self.middle_widget.setParent(self)
message_layout.addWidget(self.middle_widget)
message_layout.addSpacing(10)
if self.epilogue_msg is not None:
epilogue = QtWidgets.QLabel()
epilogue.setText(self.epilogue_msg)
epilogue.setWordWrap(True)
epilogue.setOpenExternalLinks(True)
message_layout.addWidget(epilogue)
message_layout.addSpacing(10)
return message_layout
class CollapsibleBox(QtWidgets.QWidget):
"""Create a widget that can show/hide its contents when you click on it.
The credits for this code go to eyllanesc's answer in StackOverflow:
https://stackoverflow.com/a/52617714. We have made the following improvements:
1. Adapt the code to PySide.
2. Resize the window once the box uncollapses.
3. Add type hints.
"""
def __init__(self, title: str, parent: Optional[QtWidgets.QWidget] = None):
super(CollapsibleBox, self).__init__(parent)
self.toggle_button = QtWidgets.QToolButton(
text=title,
checkable=True,
checked=False,
)
self.toggle_button.setStyleSheet("QToolButton { border: none; }")
self.toggle_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
self.toggle_button.setArrowType(QtCore.Qt.RightArrow)
self.toggle_button.clicked.connect(self.on_click)
self.toggle_animation = QtCore.QParallelAnimationGroup(self)
self.content_area = QtWidgets.QScrollArea(maximumHeight=0, minimumHeight=0)
self.content_area.setSizePolicy(
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed
)
self.content_area.setFrameShape(QtWidgets.QFrame.NoFrame)
lay = QtWidgets.QVBoxLayout(self)
lay.setSpacing(0)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.toggle_button)
lay.addWidget(self.content_area)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self, b"minimumHeight")
)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self, b"maximumHeight")
)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self.content_area, b"maximumHeight")
)
self.toggle_animation.finished.connect(self.on_animation_finished)
def on_click(self) -> None:
checked = self.toggle_button.isChecked()
self.toggle_button.setArrowType(
QtCore.Qt.DownArrow if checked else QtCore.Qt.RightArrow
)
self.toggle_animation.setDirection(
QtCore.QAbstractAnimation.Forward
if checked
else QtCore.QAbstractAnimation.Backward
)
self.toggle_animation.start()
def on_animation_finished(self) -> None:
if not self.toggle_button.isChecked():
content_height = self.content_area.layout().sizeHint().height()
parent = self.parent()
assert isinstance(parent, QtWidgets.QWidget)
parent.resize(parent.width(), parent.height() - content_height)
def setContentLayout(self, layout: QtWidgets.QBoxLayout) -> None:
lay = self.content_area.layout()
del lay
self.content_area.setLayout(layout)
collapsed_height = self.sizeHint().height() - self.content_area.maximumHeight()
content_height = layout.sizeHint().height()
for i in range(self.toggle_animation.animationCount()):
animation = self.toggle_animation.animationAt(i)
assert isinstance(animation, QtCore.QPropertyAnimation)
animation.setDuration(60)
animation.setStartValue(collapsed_height)
animation.setEndValue(collapsed_height + content_height)
content_animation = self.toggle_animation.animationAt(
self.toggle_animation.animationCount() - 1
)
assert isinstance(content_animation, QtCore.QPropertyAnimation)
content_animation.setDuration(60)
content_animation.setStartValue(0)
content_animation.setEndValue(content_height)

File diff suppressed because it is too large Load diff

35
dangerzone/gui/systray.py Normal file
View file

@ -0,0 +1,35 @@
import platform
from PySide2 import QtWidgets
class SysTray(QtWidgets.QSystemTrayIcon):
def __init__(self, global_common, gui_common, app, app_wrapper):
super(SysTray, self).__init__()
self.global_common = global_common
self.gui_common = gui_common
self.app = app
self.app_wrapper = app_wrapper
self.setIcon(self.gui_common.get_window_icon())
menu = QtWidgets.QMenu()
if platform.system() == "Darwin":
self.status_action = menu.addAction("...")
self.status_action.setEnabled(False)
menu.addSeparator()
self.new_action = menu.addAction("New window")
self.new_action.triggered.connect(self.new_window)
self.quit_action = menu.addAction("Quit")
self.quit_action.triggered.connect(self.quit_clicked)
self.setContextMenu(menu)
self.show()
def new_window(self):
self.app_wrapper.new_window.emit()
def quit_clicked(self):
self.app.quit()

View file

@ -1,315 +0,0 @@
"""A module that contains the logic for checking for updates."""
import json
import logging
import platform
import sys
import time
import typing
from typing import Optional
from packaging import version
if typing.TYPE_CHECKING:
from PySide2 import QtCore, QtWidgets
else:
try:
from PySide6 import QtCore, QtWidgets
except ImportError:
from PySide2 import QtCore, QtWidgets
# XXX implict import for "markdown" module required for Cx_Freeze to build on Windows
# See https://github.com/freedomofpress/dangerzone/issues/501
import html.parser # noqa: F401
import markdown
import requests
from ..util import get_version
from .logic import Alert, DangerzoneGui
log = logging.getLogger(__name__)
MSG_CONFIRM_UPDATE_CHECKS = """\
<p><b>Do you want Dangerzone to automatically check for updates?</b></p>
<p>If you accept, Dangerzone will check the
<a href="https://github.com/freedomofpress/dangerzone/releases">latest releases page</a>
in github.com on startup. Otherwise it will make no network requests and
won't inform you about new releases.</p>
<p>If you prefer another way of getting notified about new releases, we suggest adding
to your RSS reader our
<a href="https://fosstodon.org/@dangerzone.rss">Mastodon feed</a>. For more information
about updates, check
<a href="https://github.com/freedomofpress/dangerzone/wiki/Updates">this webpage</a>.</p>
"""
UPDATE_CHECK_COOLDOWN_SECS = 60 * 60 * 12 # Check for updates at most every 12 hours.
class UpdateCheckPrompt(Alert):
"""The prompt that asks the users if they want to enable update checks."""
x_pressed = False
def closeEvent(self, event: QtCore.QEvent) -> None:
"""Detect when a user has pressed "X" in the title bar.
This function is called when a user clicks on "X" in the title bar. We want to
differentiate between the user clicking on "Cancel" and clicking on "X", since
in the second case, we want to remind them again on the next run.
See: https://stackoverflow.com/questions/70851063/pyqt-differentiate-between-close-function-and-title-bar-close-x
"""
self.x_pressed = True
event.accept()
def create_buttons_layout(self) -> QtWidgets.QHBoxLayout:
buttons_layout = QtWidgets.QHBoxLayout()
buttons_layout.addStretch()
assert self.cancel_button is not None
buttons_layout.addWidget(self.cancel_button)
buttons_layout.addWidget(self.ok_button)
self.ok_button.setDefault(True)
return buttons_layout
class UpdateReport:
"""A report for an update check."""
def __init__(
self,
version: Optional[str] = None,
changelog: Optional[str] = None,
error: Optional[str] = None,
):
self.version = version
self.changelog = changelog
self.error = error
def empty(self) -> bool:
return self.version is None and self.changelog is None and self.error is None
class UpdaterThread(QtCore.QThread):
"""Check asynchronously for Dangerzone updates.
The Updater class is mainly responsible for the following:
1. Asking the user if they want to enable update checks or not.
2. Determining when it's the right time to check for updates.
3. Hitting the GitHub releases API and learning about updates.
Since checking for updates is a task that may take some time, we perform it
asynchronously, in a Qt thread. This thread then triggers a signal, and informs
whoever has connected to it.
"""
finished = QtCore.Signal(UpdateReport)
GH_RELEASE_URL = (
"https://api.github.com/repos/freedomofpress/dangerzone/releases/latest"
)
REQ_TIMEOUT = 15
def __init__(self, dangerzone: DangerzoneGui):
super().__init__()
self.dangerzone = dangerzone
###########
# Helpers for updater settings
#
# These helpers make it easy to retrieve specific updater-related settings, as well
# as save the settings file, only when necessary.
@property
def check(self) -> Optional[bool]:
return self.dangerzone.settings.get("updater_check")
@check.setter
def check(self, val: bool) -> None:
self.dangerzone.settings.set("updater_check", val, autosave=True)
def prompt_for_checks(self) -> Optional[bool]:
"""Ask the user if they want to be informed about Dangerzone updates."""
log.debug("Prompting the user for update checks")
# FIXME: Handle the case where a user clicks on "X", instead of explicitly
# making a choice. We should probably ask them again on the next run.
prompt = UpdateCheckPrompt(
self.dangerzone,
message=MSG_CONFIRM_UPDATE_CHECKS,
ok_text="Check Automatically",
cancel_text="Don't Check",
)
check = prompt.launch()
if not check and prompt.x_pressed:
return None
return bool(check)
def should_check_for_updates(self) -> bool:
"""Determine if we can check for updates based on settings and user prefs.
Note that this method only checks if the user has expressed an interest for
learning about new updates, and not whether we should actually make an update
check. Those two things are distinct, actually. For example:
* A user may have expressed that they want to learn about new updates.
* A previous update check may have found out that there's a new version out.
* Thus we will always show to the user the cached info about the new version,
and won't make a new update check.
"""
log.debug("Checking platform type")
# TODO: Disable updates for Homebrew installations.
if platform.system() == "Linux" and not getattr(sys, "dangerzone_dev", False):
log.debug("Running on Linux, disabling updates")
if not self.check: # if not overidden by user
self.check = False
return False
log.debug("Checking if first run of Dangerzone")
if self.dangerzone.settings.get("updater_last_check") is None:
log.debug("Dangerzone is running for the first time, updates are stalled")
self.dangerzone.settings.set("updater_last_check", 0, autosave=True)
return False
log.debug("Checking if user has already expressed their preference")
if self.check is None:
log.debug("User has not been asked yet for update checks")
self.check = self.prompt_for_checks()
return bool(self.check)
elif not self.check:
log.debug("User has expressed that they don't want to check for updates")
return False
return True
def can_update(self, cur_version: str, latest_version: str) -> bool:
if version.parse(cur_version) == version.parse(latest_version):
return False
elif version.parse(cur_version) > version.parse(latest_version):
# FIXME: This is a sanity check, but we should improve its wording.
raise Exception("Received version is older than the latest version")
else:
return True
def _get_now_timestamp(self) -> int:
return int(time.time())
def _should_postpone_update_check(self) -> bool:
"""Consult and update cooldown timer.
If the previous check happened before the cooldown period expires, do not check
again.
"""
current_time = self._get_now_timestamp()
last_check = self.dangerzone.settings.get("updater_last_check")
if current_time < last_check + UPDATE_CHECK_COOLDOWN_SECS:
log.debug("Cooling down update checks")
return True
else:
return False
def get_latest_info(self) -> UpdateReport:
"""Get the latest release info from GitHub.
Also, render the changelog from Markdown format to HTML, so that we can show it
to the users.
"""
try:
res = requests.get(self.GH_RELEASE_URL, timeout=self.REQ_TIMEOUT)
except Exception as e:
raise RuntimeError(
f"Encountered an exception while checking {self.GH_RELEASE_URL}: {e}"
)
if res.status_code != 200:
raise RuntimeError(
f"Encountered an HTTP {res.status_code} error while checking"
f" {self.GH_RELEASE_URL}"
)
try:
info = res.json()
except json.JSONDecodeError:
raise ValueError(f"Received a non-JSON response from {self.GH_RELEASE_URL}")
try:
version = info["tag_name"].lstrip("v")
changelog = markdown.markdown(info["body"])
except KeyError:
raise ValueError(
f"Missing required fields in JSON response from {self.GH_RELEASE_URL}"
)
return UpdateReport(version=version, changelog=changelog)
# XXX: This happens in parallel with other tasks. DO NOT alter global state!
def _check_for_updates(self) -> UpdateReport:
"""Check for updates locally and remotely.
Check for updates in two places:
1. In our settings, in case we have cached the latest version/changelog from a
previous run.
2. In GitHub, by hitting the latest releases API.
"""
log.debug("Checking for Dangerzone updates")
latest_version = self.dangerzone.settings.get("updater_latest_version")
if version.parse(get_version()) < version.parse(latest_version):
log.debug("Determined that there is an update due to cached results")
return UpdateReport(
version=latest_version,
changelog=self.dangerzone.settings.get("updater_latest_changelog"),
)
# If the previous check happened before the cooldown period expires, do not
# check again. Else, bump the last check timestamp, before making the actual
# check. This is to ensure that even failed update checks respect the cooldown
# period.
if self._should_postpone_update_check():
return UpdateReport()
else:
self.dangerzone.settings.set(
"updater_last_check", self._get_now_timestamp(), autosave=True
)
log.debug("Checking the latest GitHub release")
report = self.get_latest_info()
log.debug(f"Latest version in GitHub is {report.version}")
if report.version and self.can_update(latest_version, report.version):
log.debug(
f"Determined that there is an update due to a new GitHub version:"
f" {latest_version} < {report.version}"
)
return report
log.debug("No need to update")
return UpdateReport()
##################
# Logic for running update checks asynchronously
def check_for_updates(self) -> UpdateReport:
"""Check for updates and return a report with the findings:
There are three scenarios when we check for updates, and each scenario returns a
slightly different answer:
1. No new updates: Return an empty update report.
2. Updates are available: Return an update report with the latest version and
changelog, in HTML format.
3. Update check failed: Return an update report that holds just the error
message.
"""
try:
res = self._check_for_updates()
except Exception as e:
log.exception("Encountered an error while checking for upgrades")
res = UpdateReport(error=str(e))
return res
def run(self) -> None:
self.finished.emit(self.check_for_updates())

View file

@ -1,388 +0,0 @@
import contextlib
import logging
import os
import platform
import signal
import subprocess
import sys
import threading
from abc import ABC, abstractmethod
from io import BytesIO
from typing import IO, Callable, Iterator, Optional
import fitz
from colorama import Fore, Style
from ..conversion import errors
from ..conversion.common import DEFAULT_DPI, INT_BYTES
from ..document import Document
from ..util import get_tessdata_dir, replace_control_chars
log = logging.getLogger(__name__)
TIMEOUT_EXCEPTION = 15
TIMEOUT_GRACE = 15
TIMEOUT_FORCE = 5
def _signal_process_group(p: subprocess.Popen, signo: int) -> None:
"""Send a signal to a process group."""
try:
os.killpg(os.getpgid(p.pid), signo)
except (ProcessLookupError, PermissionError):
# If the process no longer exists, we may encounter the above errors, either
# when looking for the process group (ProcessLookupError), or when trying to
# kill a process group that no longer exists (PermissionError)
return
except Exception:
log.exception(
f"Unexpected error while sending signal {signo} to the"
f"document-to-pixels process group (PID: {p.pid})"
)
def terminate_process_group(p: subprocess.Popen) -> None:
"""Terminate a process group."""
if platform.system() == "Windows":
p.terminate()
else:
_signal_process_group(p, signal.SIGTERM)
def kill_process_group(p: subprocess.Popen) -> None:
"""Forcefully kill a process group."""
if platform.system() == "Windows":
p.kill()
else:
_signal_process_group(p, signal.SIGKILL)
def read_bytes(f: IO[bytes], size: int, exact: bool = True) -> bytes:
"""Read bytes from a file-like object."""
buf = f.read(size)
if exact and len(buf) != size:
raise errors.ConverterProcException()
return buf
def read_int(f: IO[bytes]) -> int:
"""Read 2 bytes from a file-like object, and decode them as int."""
untrusted_int = f.read(INT_BYTES)
if len(untrusted_int) != INT_BYTES:
raise errors.ConverterProcException()
return int.from_bytes(untrusted_int, "big", signed=False)
def sanitize_debug_text(text: bytes) -> str:
"""Read all the buffer and return a sanitized version"""
untrusted_text = text.decode("ascii", errors="replace")
return replace_control_chars(untrusted_text, keep_newlines=True)
class IsolationProvider(ABC):
"""
Abstracts an isolation provider
"""
def __init__(self, debug: bool = False) -> None:
self.debug = debug
if self.should_capture_stderr():
self.proc_stderr = subprocess.PIPE
else:
self.proc_stderr = subprocess.DEVNULL
def should_capture_stderr(self) -> bool:
return self.debug or getattr(sys, "dangerzone_dev", False)
@abstractmethod
def install(self) -> bool:
pass
def convert(
self,
document: Document,
ocr_lang: Optional[str],
progress_callback: Optional[Callable] = None,
) -> None:
self.progress_callback = progress_callback
document.mark_as_converting()
try:
with self.doc_to_pixels_proc(document) as conversion_proc:
self.convert_with_proc(document, ocr_lang, conversion_proc)
document.mark_as_safe()
if document.archive_after_conversion:
document.archive()
except errors.ConversionException as e:
self.print_progress(document, True, str(e), 0)
document.mark_as_failed()
except Exception as e:
log.exception(
f"An exception occurred while converting document '{document.id}'"
)
self.print_progress(document, True, str(e), 0)
document.mark_as_failed()
def ocr_page(self, pixmap: fitz.Pixmap, ocr_lang: str) -> bytes:
"""Get a single page as pixels, OCR it, and return a PDF as bytes."""
return pixmap.pdfocr_tobytes(
compress=True,
language=ocr_lang,
tessdata=str(get_tessdata_dir()),
)
def pixels_to_pdf_page(
self,
untrusted_data: bytes,
untrusted_width: int,
untrusted_height: int,
ocr_lang: Optional[str],
) -> fitz.Document:
"""Convert a byte array of RGB pixels into a PDF page, optionally with OCR."""
pixmap = fitz.Pixmap(
fitz.Colorspace(fitz.CS_RGB),
untrusted_width,
untrusted_height,
untrusted_data,
False,
)
pixmap.set_dpi(DEFAULT_DPI, DEFAULT_DPI)
if ocr_lang: # OCR the document
page_pdf_bytes = self.ocr_page(pixmap, ocr_lang)
else: # Don't OCR
page_doc = fitz.Document()
page_doc.insert_file(pixmap)
page_pdf_bytes = page_doc.tobytes(deflate_images=True)
return fitz.open("pdf", page_pdf_bytes)
def convert_with_proc(
self,
document: Document,
ocr_lang: Optional[str],
p: subprocess.Popen,
) -> None:
percentage = 0.0
with open(document.input_filename, "rb") as f:
try:
assert p.stdin is not None
p.stdin.write(f.read())
p.stdin.close()
except BrokenPipeError:
raise errors.ConverterProcException()
assert p.stdout
n_pages = read_int(p.stdout)
if n_pages == 0 or n_pages > errors.MAX_PAGES:
raise errors.MaxPagesException()
step = 100 / n_pages
safe_doc = fitz.Document()
for page in range(1, n_pages + 1):
searchable = "searchable " if ocr_lang else ""
text = (
f"Converting page {page}/{n_pages} from pixels to {searchable}PDF"
)
self.print_progress(document, False, text, percentage)
width = read_int(p.stdout)
height = read_int(p.stdout)
if not (1 <= width <= errors.MAX_PAGE_WIDTH):
raise errors.MaxPageWidthException()
if not (1 <= height <= errors.MAX_PAGE_HEIGHT):
raise errors.MaxPageHeightException()
num_pixels = width * height * 3 # three color channels
untrusted_pixels = read_bytes(
p.stdout,
num_pixels,
)
page_pdf = self.pixels_to_pdf_page(
untrusted_pixels,
width,
height,
ocr_lang,
)
safe_doc.insert_pdf(page_pdf)
percentage += step
# Ensure nothing else is read after all bitmaps are obtained
p.stdout.close()
# Saving it with a different name first, because PyMuPDF cannot handle
# non-Unicode chars.
safe_doc.save(document.sanitized_output_filename)
os.replace(document.sanitized_output_filename, document.output_filename)
# TODO handle leftover code input
text = "Successfully converted document"
self.print_progress(document, False, text, 100)
def print_progress(
self, document: Document, error: bool, text: str, percentage: float
) -> None:
s = Style.BRIGHT + Fore.YELLOW + f"[doc {document.id}] "
s += Fore.CYAN + f"{int(percentage)}% " + Style.RESET_ALL
if error:
s += Fore.RED + text + Style.RESET_ALL
log.error(s)
else:
s += text
log.info(s)
if self.progress_callback:
self.progress_callback(error, text, percentage)
def get_proc_exception(
self, p: subprocess.Popen, timeout: int = TIMEOUT_EXCEPTION
) -> Exception:
"""Returns an exception associated with a process exit code"""
try:
error_code = p.wait(timeout)
except subprocess.TimeoutExpired:
return errors.UnexpectedConversionError(
"Encountered an I/O error during document to pixels conversion,"
f" but the conversion process is still running after {timeout} seconds"
f" (PID: {p.pid})"
)
except Exception:
return errors.UnexpectedConversionError(
"Encountered an I/O error during document to pixels conversion,"
f" but the status of the conversion process is unknown (PID: {p.pid})"
)
return errors.exception_from_error_code(error_code)
@abstractmethod
def should_wait_install(self) -> bool:
"""Whether this isolation provider takes a lot of time to install."""
pass
@abstractmethod
def is_available(self) -> bool:
"""Whether the backing implementation of the isolation provider is available."""
pass
@abstractmethod
def get_max_parallel_conversions(self) -> int:
pass
@abstractmethod
def start_doc_to_pixels_proc(self, document: Document) -> subprocess.Popen:
pass
@abstractmethod
def terminate_doc_to_pixels_proc(
self, document: Document, p: subprocess.Popen
) -> None:
"""Terminate gracefully the process started for the doc-to-pixels phase."""
pass
def ensure_stop_doc_to_pixels_proc(
self,
document: Document,
p: subprocess.Popen,
timeout_grace: int = TIMEOUT_GRACE,
timeout_force: int = TIMEOUT_FORCE,
) -> None:
"""Stop the conversion process, or ensure it has exited.
This method should be called when we want to verify that the doc-to-pixels
process has exited, or terminate it ourselves. The termination should happen as
gracefully as possible, and we should not block indefinitely until the process
has exited.
"""
# Check if the process completed.
ret = p.poll()
if ret is not None:
return
# At this point, the process is still running. This may be benign, as we haven't
# waited for it yet. Terminate it gracefully.
self.terminate_doc_to_pixels_proc(document, p)
try:
p.wait(timeout_grace)
except subprocess.TimeoutExpired:
log.warning(
f"Conversion process did not terminate gracefully after {timeout_grace}"
" seconds. Killing it forcefully..."
)
# Forcefully kill the running process.
kill_process_group(p)
try:
p.wait(timeout_force)
except subprocess.TimeoutExpired:
log.warning(
"Conversion process did not terminate forcefully after"
f" {timeout_force} seconds. Resources may linger..."
)
@contextlib.contextmanager
def doc_to_pixels_proc(
self,
document: Document,
timeout_exception: int = TIMEOUT_EXCEPTION,
timeout_grace: int = TIMEOUT_GRACE,
timeout_force: int = TIMEOUT_FORCE,
) -> Iterator[subprocess.Popen]:
"""Start a conversion process, pass it to the caller, and then clean it up."""
# Store the proc stderr in memory
stderr = BytesIO()
p = self.start_doc_to_pixels_proc(document)
stderr_thread = self.start_stderr_thread(p, stderr)
if platform.system() != "Windows":
assert os.getpgid(p.pid) != os.getpgid(os.getpid()), (
"Parent shares same PGID with child"
)
try:
yield p
except errors.ConverterProcException as e:
exception = self.get_proc_exception(p, timeout_exception)
raise exception from e
finally:
self.ensure_stop_doc_to_pixels_proc(
document, p, timeout_grace=timeout_grace, timeout_force=timeout_force
)
if stderr_thread:
# Wait for the thread to complete. If it's still alive, mention it in the debug log.
stderr_thread.join(timeout=1)
debug_bytes = stderr.getvalue()
debug_log = sanitize_debug_text(debug_bytes)
incomplete = "(incomplete) " if stderr_thread.is_alive() else ""
log.info(
"Conversion output (doc to pixels)\n"
f"----- DOC TO PIXELS LOG START {incomplete}-----\n"
f"{debug_log}" # no need for an extra newline here
"----- DOC TO PIXELS LOG END -----"
)
def start_stderr_thread(
self, process: subprocess.Popen, stderr: IO[bytes]
) -> Optional[threading.Thread]:
"""Start a thread to read stderr from the process"""
def _stream_stderr(process_stderr: IO[bytes]) -> None:
try:
for line in process_stderr:
stderr.write(line)
except (ValueError, IOError) as e:
log.debug(f"Stderr stream closed: {e}")
if process.stderr:
stderr_thread = threading.Thread(
target=_stream_stderr,
args=(process.stderr,),
daemon=True,
)
stderr_thread.start()
return stderr_thread
return None

View file

@ -1,340 +0,0 @@
import logging
import os
import platform
import shlex
import subprocess
from typing import List, Tuple
from .. import container_utils, errors
from ..container_utils import Runtime
from ..document import Document
from ..util import get_resource_path, get_subprocess_startupinfo
from .base import IsolationProvider, terminate_process_group
TIMEOUT_KILL = 5 # Timeout in seconds until the kill command returns.
MINIMUM_DOCKER_DESKTOP = {
"Darwin": "4.40.0",
"Windows": "4.40.0",
}
# Define startupinfo for subprocesses
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO() # type: ignore [attr-defined]
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore [attr-defined]
else:
startupinfo = None
log = logging.getLogger(__name__)
class Container(IsolationProvider):
# Name of the dangerzone container
@staticmethod
def get_runtime_security_args() -> List[str]:
"""Security options applicable to the outer Dangerzone container.
Our security precautions for the outer Dangerzone container are the following:
* Do not let the container assume new privileges.
* Drop all capabilities, except for CAP_SYS_CHROOT, which is necessary for
running gVisor.
* Do not allow access to the network stack.
* Run the container as the unprivileged `dangerzone` user.
* Set the `container_engine_t` SELinux label, which allows gVisor to work on
SELinux-enforcing systems
(see https://github.com/freedomofpress/dangerzone/issues/880).
* Set a custom seccomp policy for every container engine, since the `ptrace(2)`
system call is forbidden by some.
For Podman specifically, where applicable, we also add the following:
* Do not log the container's output.
* Do not map the host user to the container, with `--userns nomap` (available
from Podman 4.1 onwards)
"""
runtime = Runtime()
if runtime.name == "podman":
security_args = ["--log-driver", "none"]
security_args += ["--security-opt", "no-new-privileges"]
if container_utils.get_runtime_version() >= (4, 1):
# We perform a platform check to avoid the following Podman Desktop
# error on Windows:
#
# Error: nomap is only supported in rootless mode
#
# See also: https://github.com/freedomofpress/dangerzone/issues/1127
if platform.system() != "Windows":
security_args += ["--userns", "nomap"]
else:
security_args = ["--security-opt=no-new-privileges:true"]
# We specify a custom seccomp policy uniformly, because on certain container
# engines the default policy might not allow the `ptrace(2)` syscall [1]. Our
# custom seccomp policy has been copied as is [2] from the official Podman repo.
#
# [1] https://github.com/freedomofpress/dangerzone/issues/846
# [2] https://github.com/containers/common/blob/d3283f8401eeeb21f3c59a425b5461f069e199a7/pkg/seccomp/seccomp.json
seccomp_json_path = str(get_resource_path("seccomp.gvisor.json"))
# We perform a platform check to avoid the following Podman Desktop
# error on Windows:
#
# Error: opening seccomp profile failed: open
# C:\[...]\dangerzone\share\seccomp.gvisor.json: no such file or directory
#
# See also: https://github.com/freedomofpress/dangerzone/issues/1127
if runtime.name == "podman" and platform.system() != "Windows":
security_args += ["--security-opt", f"seccomp={seccomp_json_path}"]
security_args += ["--cap-drop", "all"]
security_args += ["--cap-add", "SYS_CHROOT"]
security_args += ["--security-opt", "label=type:container_engine_t"]
security_args += ["--network=none"]
security_args += ["-u", "dangerzone"]
return security_args
@staticmethod
def install() -> bool:
"""Install the container image tarball, or verify that it's already installed.
Perform the following actions:
1. Get the tags of any locally available images that match Dangerzone's image
name.
2. Get the expected image tag from the image-id.txt file.
- If this tag is present in the local images, then we can return.
- Else, prune the older container images and continue.
3. Load the image tarball and make sure it matches the expected tag.
"""
old_tags = container_utils.list_image_tags()
expected_tag = container_utils.get_expected_tag()
if expected_tag not in old_tags:
# Prune older container images.
log.info(
f"Could not find a Dangerzone container image with tag '{expected_tag}'"
)
for tag in old_tags:
tag = container_utils.CONTAINER_NAME + ":" + tag
container_utils.delete_image_tag(tag)
else:
return True
# Load the image tarball into the container runtime.
container_utils.load_image_tarball()
# Check that the container image has the expected image tag.
# See https://github.com/freedomofpress/dangerzone/issues/988 for an example
# where this was not the case.
new_tags = container_utils.list_image_tags()
if expected_tag not in new_tags:
raise errors.ImageNotPresentException(
f"Could not find expected tag '{expected_tag}' after loading the"
" container image tarball"
)
return True
@staticmethod
def should_wait_install() -> bool:
return True
@staticmethod
def is_available() -> bool:
runtime = Runtime()
# Can we run `docker/podman image ls` without an error
with subprocess.Popen(
[str(runtime.path), "image", "ls"],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
startupinfo=get_subprocess_startupinfo(),
) as p:
_, stderr = p.communicate()
if p.returncode != 0:
raise errors.NotAvailableContainerTechException(
runtime.name, stderr.decode()
)
return True
def check_docker_desktop_version(self) -> Tuple[bool, str]:
# On windows and darwin, check that the minimum version is met
version = ""
runtime = Runtime()
runtime_is_docker = runtime.name == "docker"
platform_is_not_linux = platform.system() != "Linux"
if runtime_is_docker and platform_is_not_linux:
with subprocess.Popen(
["docker", "version", "--format", "{{.Server.Platform.Name}}"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
startupinfo=get_subprocess_startupinfo(),
) as p:
stdout, stderr = p.communicate()
if p.returncode != 0:
# When an error occurs, consider that the check went
# through, as we're checking for installation compatibiliy
# somewhere else already
return True, version
# The output is like "Docker Desktop 4.35.1 (173168)"
version = stdout.decode().replace("Docker Desktop", "").split()[0]
if version < MINIMUM_DOCKER_DESKTOP[platform.system()]:
return False, version
return True, version
def doc_to_pixels_container_name(self, document: Document) -> str:
"""Unique container name for the doc-to-pixels phase."""
return f"dangerzone-doc-to-pixels-{document.id}"
def pixels_to_pdf_container_name(self, document: Document) -> str:
"""Unique container name for the pixels-to-pdf phase."""
return f"dangerzone-pixels-to-pdf-{document.id}"
def exec(
self,
args: List[str],
) -> subprocess.Popen:
args_str = " ".join(shlex.quote(s) for s in args)
log.info("> " + args_str)
return subprocess.Popen(
args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=self.proc_stderr,
startupinfo=startupinfo,
# Start the conversion process in a new session, so that we can later on
# kill the process group, without killing the controlling script.
start_new_session=True,
)
def exec_container(
self,
command: List[str],
name: str,
) -> subprocess.Popen:
runtime = Runtime()
security_args = self.get_runtime_security_args()
debug_args = []
if self.debug:
debug_args += ["-e", "RUNSC_DEBUG=1"]
enable_stdin = ["-i"]
set_name = ["--name", name]
prevent_leakage_args = ["--rm"]
image_name = [
container_utils.CONTAINER_NAME + ":" + container_utils.get_expected_tag()
]
args = (
["run"]
+ security_args
+ debug_args
+ prevent_leakage_args
+ enable_stdin
+ set_name
+ image_name
+ command
)
return self.exec([str(runtime.path)] + args)
def kill_container(self, name: str) -> None:
"""Terminate a spawned container.
We choose to terminate spawned containers using the `kill` action that the
container runtime provides, instead of terminating the process that spawned
them. The reason is that this process is not always tied to the underlying
container. For instance, in Docker containers, this process is actually
connected to the Docker daemon, and killing it will just close the associated
standard streams.
"""
runtime = Runtime()
cmd = [str(runtime.path), "kill", name]
try:
# We do not check the exit code of the process here, since the container may
# have stopped right before invoking this command. In that case, the
# command's output will contain some error messages, so we capture them in
# order to silence them.
#
# NOTE: We specify a timeout for this command, since we've seen it hang
# indefinitely for specific files. See:
# https://github.com/freedomofpress/dangerzone/issues/854
subprocess.run(
cmd,
capture_output=True,
startupinfo=get_subprocess_startupinfo(),
timeout=TIMEOUT_KILL,
)
except subprocess.TimeoutExpired:
log.warning(
f"Could not kill container '{name}' within {TIMEOUT_KILL} seconds"
)
except Exception as e:
log.exception(
f"Unexpected error occurred while killing container '{name}': {str(e)}"
)
def start_doc_to_pixels_proc(self, document: Document) -> subprocess.Popen:
# Convert document to pixels
command = [
"/usr/bin/python3",
"-m",
"dangerzone.conversion.doc_to_pixels",
]
name = self.doc_to_pixels_container_name(document)
return self.exec_container(command, name=name)
def terminate_doc_to_pixels_proc(
self, document: Document, p: subprocess.Popen
) -> None:
# There are two steps to gracefully terminate a conversion process:
# 1. Kill the container, and check that it has exited.
# 2. Gracefully terminate the conversion process, in case it's stuck on I/O
#
# See also https://github.com/freedomofpress/dangerzone/issues/791
self.kill_container(self.doc_to_pixels_container_name(document))
terminate_process_group(p)
def ensure_stop_doc_to_pixels_proc( # type: ignore [no-untyped-def]
self, document: Document, *args, **kwargs
) -> None:
super().ensure_stop_doc_to_pixels_proc(document, *args, **kwargs)
# Check if the container no longer exists, either because we successfully killed
# it, or because it exited on its own. We operate under the assumption that
# after a podman kill / docker kill invocation, this will likely be the case,
# else the container runtime (Docker/Podman) has experienced a problem, and we
# should report it.
runtime = Runtime()
name = self.doc_to_pixels_container_name(document)
all_containers = subprocess.run(
[str(runtime.path), "ps", "-a"],
capture_output=True,
startupinfo=get_subprocess_startupinfo(),
)
if name in all_containers.stdout.decode():
log.warning(f"Container '{name}' did not stop gracefully")
def get_max_parallel_conversions(self) -> int:
# FIXME hardcoded 1 until length conversions are better handled
# https://github.com/freedomofpress/dangerzone/issues/257
return 1
runtime = Runtime() # type: ignore [unreachable]
n_cpu = 1
if platform.system() == "Linux":
# if on linux containers run natively
cpu_count = os.cpu_count()
if cpu_count is not None:
n_cpu = cpu_count
elif runtime.name == "docker":
# For Windows and MacOS containers run in VM
# So we obtain the CPU count for the VM
n_cpu_str = subprocess.check_output(
[str(runtime.path), "info", "--format", "{{.NCPU}}"],
text=True,
startupinfo=get_subprocess_startupinfo(),
)
n_cpu = int(n_cpu_str.strip())
return 2 * n_cpu + 1

View file

@ -1,71 +0,0 @@
import logging
import subprocess
import sys
from ..conversion.common import DangerzoneConverter
from ..document import Document
from .base import IsolationProvider, terminate_process_group
log = logging.getLogger(__name__)
def dummy_script() -> None:
sys.stdin.buffer.read()
pages = 2
width = height = 9
DangerzoneConverter._write_int(pages)
for page in range(pages):
DangerzoneConverter._write_int(width)
DangerzoneConverter._write_int(height)
DangerzoneConverter._write_bytes(width * height * 3 * b"A")
class Dummy(IsolationProvider):
"""Dummy Isolation Provider (FOR TESTING ONLY)
"Do-nothing" converter - the sanitized files are the same as the input files.
Useful for testing without the need to use docker.
"""
def __init__(self) -> None:
# Sanity check
if not getattr(sys, "dangerzone_dev", False):
raise Exception(
"Dummy isolation provider is UNSAFE and should never be "
+ "called in a non-testing system."
)
super().__init__()
def install(self) -> bool:
return True
@staticmethod
def is_available() -> bool:
return True
@staticmethod
def should_wait_install() -> bool:
return False
def start_doc_to_pixels_proc(self, document: Document) -> subprocess.Popen:
cmd = [
sys.executable,
"-c",
"from dangerzone.isolation_provider.dummy import dummy_script;"
" dummy_script()",
]
return subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=self.proc_stderr,
start_new_session=True,
)
def terminate_doc_to_pixels_proc(
self, document: Document, p: subprocess.Popen
) -> None:
terminate_process_group(p)
def get_max_parallel_conversions(self) -> int:
return 1

View file

@ -1,135 +0,0 @@
import io
import logging
import os
import subprocess
import sys
import zipfile
from pathlib import Path
from typing import IO
from ..conversion.common import running_on_qubes
from ..document import Document
from ..util import get_resource_path
from .base import IsolationProvider
log = logging.getLogger(__name__)
class Qubes(IsolationProvider):
"""Uses a disposable qube for performing the conversion"""
def install(self) -> bool:
return True
@staticmethod
def is_available() -> bool:
return True
@staticmethod
def should_wait_install() -> bool:
return False
def get_max_parallel_conversions(self) -> int:
return 1
def start_doc_to_pixels_proc(self, document: Document) -> subprocess.Popen:
dev_mode = getattr(sys, "dangerzone_dev", False) is True
if dev_mode:
# Use dz.ConvertDev RPC call instead, if we are in development mode.
# Basically, the change is that we also transfer the necessary Python
# code as a zipfile, before sending the doc that the user requested.
qrexec_policy = "dz.ConvertDev"
stderr = subprocess.PIPE
else:
qrexec_policy = "dz.Convert"
stderr = subprocess.DEVNULL
p = subprocess.Popen(
["/usr/bin/qrexec-client-vm", "@dispvm:dz-dvm", qrexec_policy],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=stderr,
# Start the conversion process in a new session, so that we can later on
# kill the process group, without killing the controlling script.
start_new_session=True,
)
if dev_mode:
assert p.stdin is not None
# Send the dangerzone module first.
self.teleport_dz_module(p.stdin)
return p
def terminate_doc_to_pixels_proc(
self, document: Document, p: subprocess.Popen
) -> None:
"""Terminate a spawned disposable qube.
Qubes does not offer a way out of the box to terminate disposable Qubes from
domU [1]. Our best bet is to close the standard streams of the process, and hope
that the disposable qube will attempt to read/write to them, and thus receive an
EOF.
There are two ways we can do the above; close the standard streams explicitly,
or terminate the process. The problem with the latter is that terminating
`qrexec-client-vm` happens immediately, and we no longer have a way to learn if
the disposable qube actually terminated. That's why we prefer closing the
standard streams explicitly, so that we can afterwards use `Popen.wait()` to
learn if the qube terminated.
Note that we don't close the stderr stream because we want to read debug logs
from it. In the rare case where a qube cannot terminate because it's stuck
writing at stderr (this is not the expected behavior), we expect that the
process will still be forcefully killed after the soft termination timeout
expires.
[1]: https://github.com/freedomofpress/dangerzone/issues/563#issuecomment-2034803232
"""
if p.stdin:
p.stdin.close()
if p.stdout:
p.stdout.close()
def teleport_dz_module(self, wpipe: IO[bytes]) -> None:
"""Send the dangerzone module to another qube, as a zipfile."""
# Grab the absolute file path of the dangerzone module.
import dangerzone as _dz
_conv_path = Path(_dz.conversion.__file__).parent
_src_root = Path(_dz.__file__).parent.parent
temp_file = io.BytesIO()
with zipfile.ZipFile(temp_file, "w") as z:
z.mkdir("dangerzone/")
z.writestr("dangerzone/__init__.py", "")
for root, _, files in os.walk(_conv_path):
for file in files:
if file.endswith(".py"):
file_path = os.path.join(root, file)
relative_path = os.path.relpath(file_path, _src_root)
z.write(file_path, relative_path)
# Send the following data:
# 1. The size of the Python zipfile, so that the server can know when to
# stop.
# 2. The Python zipfile itself.
bufsize_bytes = len(temp_file.getvalue()).to_bytes(4, "big")
wpipe.write(bufsize_bytes)
wpipe.write(temp_file.getvalue())
def is_qubes_native_conversion() -> bool:
"""Returns True if the conversion should be run using Qubes OS's diposable
VMs and False if not."""
if running_on_qubes():
if getattr(sys, "dangerzone_dev", False):
return os.environ.get("QUBES_CONVERSION", "0") == "1"
# XXX If Dangerzone is installed check if container image was shipped
# This disambiguates if it is running a Qubes targetted build or not
# (Qubes-specific builds don't ship the container image)
return not get_resource_path("container.tar").exists()
else:
return False

View file

@ -1,92 +0,0 @@
import concurrent.futures
import json
import logging
from typing import Callable, List, Optional
import colorama
from . import errors, util
from .document import Document
from .isolation_provider.base import IsolationProvider
from .settings import Settings
from .util import get_resource_path
log = logging.getLogger(__name__)
class DangerzoneCore(object):
"""
Singleton of shared state / functionality throughout the app
"""
def __init__(self, isolation_provider: IsolationProvider) -> None:
# Initialize terminal colors
colorama.init(autoreset=True)
# Languages supported by tesseract
with get_resource_path("ocr-languages.json").open("r") as f:
unsorted_ocr_languages = json.load(f)
self.ocr_languages = dict(sorted(unsorted_ocr_languages.items()))
# Load settings
self.settings = Settings()
self.documents: List[Document] = []
self.isolation_provider = isolation_provider
def add_document_from_filename(
self,
input_filename: str,
output_filename: Optional[str] = None,
archive: bool = False,
) -> None:
doc = Document(input_filename, output_filename, archive=archive)
self.add_document(doc)
def add_document(self, doc: Document) -> None:
if doc in self.documents:
raise errors.AddedDuplicateDocumentException()
self.documents.append(doc)
def remove_document(self, doc: Document) -> None:
if doc not in self.documents:
# Sanity check: should not have reached
return
log.debug(f"Removing document {doc.input_filename}")
self.documents.remove(doc)
def clear_documents(self) -> None:
log.debug("Removing all documents")
self.documents = []
def convert_documents(
self, ocr_lang: Optional[str], stdout_callback: Optional[Callable] = None
) -> None:
def convert_doc(document: Document) -> None:
try:
self.isolation_provider.convert(
document,
ocr_lang,
stdout_callback,
)
except Exception:
log.exception(
f"Unexpected error occurred while converting '{document}'"
)
document.mark_as_failed()
max_jobs = self.isolation_provider.get_max_parallel_conversions()
with concurrent.futures.ThreadPoolExecutor(max_workers=max_jobs) as executor:
executor.map(convert_doc, self.documents)
def get_unconverted_documents(self) -> List[Document]:
return [doc for doc in self.documents if doc.is_unconverted()]
def get_safe_documents(self) -> List[Document]:
return [doc for doc in self.documents if doc.is_safe()]
def get_failed_documents(self) -> List[Document]:
return [doc for doc in self.documents if doc.is_failed()]
def get_converting_documents(self) -> List[Document]:
return [doc for doc in self.documents if doc.is_converting()]

View file

@ -1,82 +1,30 @@
import json
import logging
import os import os
from pathlib import Path import json
from typing import TYPE_CHECKING, Any, Dict import appdirs
from packaging import version
from .document import SAFE_EXTENSION
from .util import get_config_dir, get_version
log = logging.getLogger(__name__)
SETTINGS_FILENAME: str = "settings.json"
class Settings: class Settings:
settings: Dict[str, Any] def __init__(self, common):
self.common = common
def __init__(self) -> None: self.settings_filename = os.path.join(self.common.appdata_path, "settings.json")
self.settings_filename = get_config_dir() / SETTINGS_FILENAME self.default_settings = {
self.default_settings: Dict[str, Any] = self.generate_default_settings()
self.load()
@classmethod
def generate_default_settings(cls) -> Dict[str, Any]:
return {
"save": True, "save": True,
"archive": True,
"ocr": True, "ocr": True,
"ocr_language": "English", "ocr_language": "English",
"open": True, "open": True,
"open_app": None, "open_app": None,
"safe_extension": SAFE_EXTENSION,
"updater_check": None,
"updater_last_check": None, # last check in UNIX epoch (secs since 1970)
# FIXME: How to invalidate those if they change upstream?
"updater_latest_version": get_version(),
"updater_latest_changelog": "",
"updater_errors": 0,
} }
def custom_runtime_specified(self) -> bool: self.load()
return "container_runtime" in self.settings
def set_custom_runtime(self, runtime: str, autosave: bool = False) -> Path: def get(self, key):
from .container_utils import Runtime # Avoid circular import
container_runtime = Runtime.path_from_name(runtime)
self.settings["container_runtime"] = str(container_runtime)
if autosave:
self.save()
return container_runtime
def unset_custom_runtime(self) -> None:
self.settings.pop("container_runtime")
self.save()
def get(self, key: str) -> Any:
return self.settings[key] return self.settings[key]
def set(self, key: str, val: Any, autosave: bool = False) -> None: def set(self, key, val):
try:
old_val = self.get(key)
except KeyError:
old_val = None
self.settings[key] = val self.settings[key] = val
if autosave and val != old_val:
self.save()
def get_updater_settings(self) -> Dict[str, Any]: def load(self):
return {
key: val for key, val in self.settings.items() if key.startswith("updater_")
}
def load(self) -> None:
if os.path.isfile(self.settings_filename): if os.path.isfile(self.settings_filename):
self.settings = self.default_settings
# If the settings file exists, load it # If the settings file exists, load it
try: try:
with open(self.settings_filename, "r") as settings_file: with open(self.settings_filename, "r") as settings_file:
@ -86,22 +34,19 @@ class Settings:
for key in self.default_settings: for key in self.default_settings:
if key not in self.settings: if key not in self.settings:
self.settings[key] = self.default_settings[key] self.settings[key] = self.default_settings[key]
elif key == "updater_latest_version":
if version.parse(get_version()) > version.parse(self.get(key)):
self.set(key, get_version())
except Exception: except:
log.error("Error loading settings, falling back to default") print("Error loading settings, falling back to default")
self.settings = self.default_settings self.settings = self.default_settings
else: else:
# Save with default settings # Save with default settings
log.info("Settings file doesn't exist, starting with default") print("Settings file doesn't exist, starting with default")
self.settings = self.default_settings self.settings = self.default_settings
self.save() self.save()
def save(self) -> None: def save(self):
self.settings_filename.parent.mkdir(parents=True, exist_ok=True) os.makedirs(self.common.appdata_path, exist_ok=True)
with self.settings_filename.open("w") as settings_file: with open(self.settings_filename, "w") as settings_file:
json.dump(self.settings, settings_file, indent=4) json.dump(self.settings, settings_file, indent=4)

View file

@ -1,132 +0,0 @@
import platform
import subprocess
import sys
import traceback
import unicodedata
from pathlib import Path
try:
import platformdirs
except ImportError:
import appdirs as platformdirs
def get_config_dir() -> Path:
return Path(platformdirs.user_config_dir("dangerzone"))
def get_resource_path(filename: str) -> Path:
if getattr(sys, "dangerzone_dev", False):
# Look for resources directory relative to python file
project_root = Path(__file__).parent.parent
prefix = project_root / "share"
else:
if platform.system() == "Darwin":
bin_path = Path(sys.executable)
app_path = bin_path.parent.parent
prefix = app_path / "Resources" / "share"
elif platform.system() == "Linux":
prefix = Path(sys.prefix) / "share" / "dangerzone"
elif platform.system() == "Windows":
exe_path = Path(sys.executable)
dz_install_path = exe_path.parent
prefix = dz_install_path / "share"
else:
raise NotImplementedError(f"Unsupported system {platform.system()}")
return prefix / filename
def get_tessdata_dir() -> Path:
if getattr(sys, "dangerzone_dev", False) or platform.system() in (
"Windows",
"Darwin",
):
# Always use the tessdata path from the Dangerzone ./share directory, for
# development builds, or in Windows/macOS platforms.
return get_resource_path("tessdata")
# In case of Linux systems, grab the Tesseract data from any of the following
# locations. We have found some of the locations through trial and error, whereas
# others are taken from the docs:
#
# [...] Possibilities are /usr/share/tesseract-ocr/tessdata or
# /usr/share/tessdata or /usr/share/tesseract-ocr/4.00/tessdata. [1]
#
# [1] https://tesseract-ocr.github.io/tessdoc/Installation.html
tessdata_dirs = [
Path("/usr/share/tessdata/"), # on some Debian
Path("/usr/share/tesseract/tessdata/"), # on Fedora
Path("/usr/share/tesseract-ocr/tessdata/"), # ? (documented)
Path("/usr/share/tesseract-ocr/4.00/tessdata/"), # on Debian Bullseye
Path("/usr/share/tesseract-ocr/5/tessdata/"), # on Debian Trixie
]
for dir in tessdata_dirs:
if dir.is_dir():
return dir
raise RuntimeError("Tesseract language data are not installed in the system")
def get_version() -> str:
try:
with get_resource_path("version.txt").open() as f:
version = f.read().strip()
except FileNotFoundError:
# In dev mode, in Windows, get_resource_path doesn't work properly for the container, but luckily
# it doesn't need to know the version
version = "unknown"
return version
def get_subprocess_startupinfo(): # type: ignore [no-untyped-def]
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
return startupinfo
else:
return None
def replace_control_chars(untrusted_str: str, keep_newlines: bool = False) -> str:
"""Remove control characters from string. Protects a terminal emulator
from obscure control characters.
Control characters are replaced by <EFBFBD> U+FFFD Replacement Character.
If a user wants to keep the newline character (e.g., because they are sanitizing a
multi-line text), they must pass `keep_newlines=True`.
"""
def is_safe(chr: str) -> bool:
"""Return whether Unicode character is safe to print in a terminal
emulator, based on its General Category.
The following General Category values are considered unsafe:
* C* - all control character categories (Cc, Cf, Cs, Co, Cn)
* Zl - U+2028 LINE SEPARATOR only
* Zp - U+2029 PARAGRAPH SEPARATOR only
"""
categ = unicodedata.category(chr)
if categ.startswith("C") or categ in ("Zl", "Zp"):
return False
return True
sanitized_str = ""
for char in untrusted_str:
if (keep_newlines and char == "\n") or is_safe(char):
sanitized_str += char
else:
sanitized_str += "<EFBFBD>"
return sanitized_str
def format_exception(e: Exception) -> str:
# The signature of traceback.format_exception has changed in python 3.10
if sys.version_info < (3, 10):
output = traceback.format_exception(*sys.exc_info())
else:
output = traceback.format_exception(e)
return "".join(output)

29
debian/changelog vendored
View file

@ -1,29 +0,0 @@
dangerzone (0.9.0) unstable; urgency=low
* Released Dangerzone 0.9.0
-- Freedom of the Press Foundation <info@freedom.press> Mon, 31 Mar 2025 15:57:18 +0300
dangerzone (0.8.1) unstable; urgency=low
* Released Dangerzone 0.8.1
-- Freedom of the Press Foundation <info@freedom.press> Tue, 22 Dec 2024 22:03:28 +0300
dangerzone (0.8.0) unstable; urgency=low
* Released Dangerzone 0.8.0
-- Freedom of the Press Foundation <info@freedom.press> Tue, 30 Oct 2024 01:56:28 +0300
dangerzone (0.7.1) unstable; urgency=low
* Released Dangerzone 0.7.1
-- Freedom of the Press Foundation <info@freedom.press> Tue, 1 Oct 2024 17:02:28 +0300
dangerzone (0.7.0) unstable; urgency=low
* Removed stdeb in favor of direct debian packaging tools
-- Freedom of the Press Foundation <info@freedom.press> Tue, 27 Aug 2024 14:39:28 +0200

1
debian/compat vendored
View file

@ -1 +0,0 @@
10

15
debian/control vendored
View file

@ -1,15 +0,0 @@
Source: dangerzone
Maintainer: Freedom of the Press Foundation <info@freedom.press>
Section: python
Priority: optional
Build-Depends: dh-python, python3-setuptools, python3, dpkg-dev, debhelper (>= 9)
Standards-Version: 4.5.1
Homepage: https://github.com/freedomofpress/dangerzone
Rules-Requires-Root: no
Package: dangerzone
Architecture: any
Depends: ${misc:Depends}, podman, python3, python3-pyside2.qtcore, python3-pyside2.qtgui, python3-pyside2.qtwidgets, python3-pyside2.qtsvg, python3-platformdirs | python3-appdirs, python3-click, python3-xdg, python3-colorama, python3-requests, python3-markdown, python3-packaging, tesseract-ocr-all
Description: Take potentially dangerous PDFs, office documents, or images
Dangerzone is an open source desktop application that takes potentially dangerous PDFs, office documents, or images and converts them to safe PDFs. It uses disposable VMs on Qubes OS, or container technology in other OSes, to convert the documents within a secure sandbox.
.

8
debian/copyright vendored
View file

@ -1,8 +0,0 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: dangerzone
Source: https://github.com/freedomofpress/dangerzone
Files: *
Copyright: 2020-2021 First Look Media
2022- Freedom of the Press Foundation, and Dangerzone contributors
License: AGPL-3.0-or-later

13
debian/rules vendored
View file

@ -1,13 +0,0 @@
#!/usr/bin/make -f
export PYBUILD_NAME=dangerzone
export DEB_BUILD_OPTIONS=nocheck
export PYBUILD_INSTALL_ARGS=--install-lib=/usr/lib/python3/dist-packages
export PYTHONDONTWRITEBYTECODE=1
export DH_VERBOSE=1
%:
dh $@ --with python3 --buildsystem=pybuild
override_dh_builddeb:
./install/linux/debian-vendor-pymupdf.py --dest debian/dangerzone/usr/lib/python3/dist-packages/dangerzone/vendor/
dh_builddeb $@

View file

@ -1 +0,0 @@
3.0 (native)

View file

@ -1,7 +0,0 @@
compression = "gzip"
tar-ignore = "dev_scripts"
tar-ignore = ".*"
tar-ignore = "__pycache__"
# Ignore the 'share/tessdata' dir, since it slows down the process, and we
# install Tesseract data via Debian packages anyway.
tar-ignore = "share/tessdata"

View file

@ -1,7 +0,0 @@
# Developer scripts
This directory holds some scripts that are helpful for developing on Dangerzone.
Read the respective documentation for more details on some of the scripts.
* [`env.py`](../docs/developer/environments.md)
* [`qa.py`](../docs/developer/qa.md)

View file

@ -1,7 +0,0 @@
Package: *
Pin: origin "packages.freedom.press/apt-tools-prod"
Pin-Priority: 100
Package: conmon
Pin: origin "packages.freedom.press/apt-tools-prod"
Pin-Priority: 500

View file

@ -1,71 +0,0 @@
Types: deb
URIs: https://packages.freedom.press/apt-tools-prod
Suites: jammy
Components: main
Signed-By:
-----BEGIN PGP PUBLIC KEY BLOCK-----
Comment: DE28 AB24 1FA4 8260 FAC9 B8BA A7C9 B385 2260 4281
Comment: Dangerzone Release Key <dangerzone-release-key@freedom.
.
xsFNBGP2Ey4BEADSudGS33NCAeuUcHqrgNAet4bX6jAPVTNXgOGLK7DYuBNJ0aSR
1wv+PHaM8la/U2YGD31nKsEsLulzyzrdod8AlbyYPkygaYAJIa7NK7IuOtO6/52R
unpGkPFzA1oDhmOjUbkNthe3GTqDq6a5U04GRhtbY0U9j0+OREzy18IiTQVTnBRX
kP8h2H+MaQwI/J2hB7ZF4rRHbzrzfZByWZmxpjxRR56Zfdl8xgp35WDt4ohyo9n6
LrQZ6Va70EwWRFW2c+SuAF1V3HDdyfkk/NHbD7FWhNByExNCoHiFP1ZMUgqYlQVs
gAsoW9IEMGayPowMlB1IjhYa65ihyH511nsKP6l8M2cRAfVJTtV3P84JqrWJd9TE
3koSUa/yi8AO36omUWHHX9rjnj8CHLhk0GEf0c53E7Izad/DTcsVnIs6NT2e7t5u
O1FUQPFL5gHi0iWI+2jzRGNa/YvGF6amVav5dtus0bObfgPBdBo9UhqgimidX1+N
T/g25CTKhoUMz4dDycBcJQgeOOpbyPI7W7tpyBNyD3/JCga1+Kj5E+gw+MAFl+KB
gqaapbXfuIa69WSutOTKINjXVV+Wqn04uMjgcPZP+LCQWN49oS3dCflKEd/mmg63
8WMuzweDm5gYNCuGHprj3rNUjruVqHzSDwhfDuKtbDjgZ48V9CnwzNe2/wARAQAB
zT1EYW5nZXJ6b25lIFJlbGVhc2UgS2V5IDxkYW5nZXJ6b25lLXJlbGVhc2Uta2V5
QGZyZWVkb20ucHJlc3M+wsGRBBMBCAA7FiEE3iirJB+kgmD6ybi6p8mzhSJgQoEF
AmP2Ey4CGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQp8mzhSJgQoH6
BhAAkpD8fb4rJVAZuOpxWSNM0EzTjm08aFt9XZqyCxN+j+2737okhjzX5hkTbNgR
Opn2pAtBEBMXGYzyw/XIg8PKEhY9L0dE5+i0rZwodvdPWMPQ6kHizJQokSc2rWV4
bjFJrMyS3zXioh8W1zgoHLxvKTBncmdCPRX53Wf4TwZHqgimn03CHHHF9BJBix9d
8OXJbVBdjB3zRT3VuLxeq73YzNSgUYBjrNf1IYJQKnZzXO9FhfHtaxUnSde2wv9E
fhTa7fYDQz42zE2GPCcSVFUXmYB/FyHDswJAp7YtTNr8xYZ7ZkV6W1QIEWK9FVC3
ABcTrMnAGG5OFeNE5C2w5yVM+5f6sJ8H47y9fO8AvEyKRMzsD3c+AOpOgdy+4/6U
qN0lDB9mP7CXqU5lp7z42QofJtXvUAbzQquZoRZCdEK4N9G0TzUwTkWeeZ+tYgnB
FkM464DoaF1aZCGIaw3+J7XyweV2TkG9kS95khyszps1iHRUOGYzy0n8twFNWsbe
/qLZkDtYI28cnKkedHKNAysmtyNIzab+Elc3vni0BEz8d+rn5rG20kjLBDQCZnf9
UyrXTQ6ahl5UexaJC/3I46g6obHRU6F9+C+JmpRxJxIqJw3jduVB0acTttg8n3F4
DCUE5Lj/vsE8nlhFi4inU2SMtHBspR5UkKNN5sRDuqac3ovOwU0EY/YT8AEQAM1N
O3WVV41Ac1SI/fc/NjtKX2wD7GfZERbkPEWFG5n9cY/yoPbYiALKyWhRv7BWFg8e
j7eEKKuZV6U4zqsnQNTC41LPG3Umq1oWsheeOXS/q7d26nwveL0b2OekUMpjAUkX
Xboq73UxPyfq9AEzrN2P8AJ6+KBfUx1Croa9Sy49z/94IG1DuJbq5X/9WjNJ6n2d
zWF0rEnsEKP4bcympi2+hJFOJm3h+GrevOPm5nf3/6N7pRNS3BdW7UsgPfAYOhYL
2vswNGQu9rDyum11TLzEnTctWDfcnTmvU/cmMup6jx6j8PLotfhT82Ua3DhBiTfC
RWEEL/vvoiFVmcqOxCX5d7KrSlPUcJR3/438/Rw8W0PlrSW1DT+eMD86uynH4kMs
FIXGQMZ5OyrPkSQf9RMj93fp6zuh+eqAwOmR460wA/S6q74i0ZD+hTDjz3X178nH
4CLsl3mGTGu3qlM6wY+gaObzdJFhQsQ014lZ1DuLGRaQY9GVkFftyIUJiDnIJVP2
Zw8i/3j197cBh4NxAyHRU1m1gP2rhIAh2vT1iA1cXSpf7TZNMFZvYR4IgdWac78Y
pFuIzFkuFLRYl2oCRp9Q4eS4J/VkoKSe4jGNiPBl+brubsUye7PsWIrUm0ZT2rXa
cIy/W59zJT6pP2J2yM4TjCsXCyxyrwD3ultOhpNnABEBAAHCw6wEGAEIACAWIQTe
KKskH6SCYPrJuLqnybOFImBCgQUCY/YT8AIbAgJACRCnybOFImBCgcF0IAQZAQgA
HRYhBATKvrXddrrPK9Q9L/Osxg9i6lHLBQJj9hPwAAoJEPOsxg9i6lHLrokP/2Mf
M+z3xy7eT06saDMvJ4X/jkP7c8OwjHcJW6nfwVrxDua/MwZrnNbBvs0uolBbljuG
B873tCiE5Hx19lUr+o1FUxcMFb6zisSZNZv7FleSYfuyZ2jMMysNrCdATqMJojpj
XURgr/6LAG/ZEV6egOL7SWQdAw33JBiPLXsRgUeR9QhZjYV+dNDYYzkVlctK7g1h
mgjKbz7Qu0K4d/nDIUpJlWnNGsmZEObcEQq79GzP6QoUGF86jHurTtJyJYhliyJz
J+5Ph8hzHSK5H0Z36i+H4LwOL/p4kVJWMRZLLCFXU2IeJk6oDQMkH9fC74b6RB1C
BbM4AvzxxvgnLdKHBLQ10yum3Iztty7uAg5OMRK+OoZfZpZrcK0uZK1UWjAoHnrr
h8SqceXwc195ujdnYW+G4Pk3F6OHN7N0Rfp2LN23F/Hbwsz+RKTucCXb8E4cgS14
4l2O8vaM0VnhKprO5GDk7IY6Q+A7eRnveIMlBitX1Snzgd1+oA5bkCHzEUmQMNUs
Cqm6iQEqGjEVyRxbCyC+xv+YbpawPm64QSBcR6t0pR3Py9l4HVzVqtN5AHgSN5yp
NEXEyvhDJT4WEOkJlGwxfGtVzMx01qiWMOjsTzg21qWWvg0+acnU6yMpVxHbG9hb
Lfz9p42Cc91dUULDg2kVHpOA0+VBkmtKUgNV59rLko8P/jm6JgnJIzNS3a3Zt4HM
2zGy6r6OpLvgTB4WMRPT/tcxMhky+m/RqNXA3J9U83potE28bc10ZOpCwL/RBt+S
631Bq2ISFmKCMqVSvlOCGmW4DA52kO82V20E+ijuTMe/bAiarTxeXWkFAptq8Xo1
6TJdcy4uluhTz23iXeRF09S6Cs6v+RmOTXcRR9FbeLtJhp8h+vhHlqN9JS/k+1dn
54dRk6ioQO+rrneWSMIqf/Vz9W8YnKMuSP8pCzagGnBsUcZCgDKol01QETgwbUvk
QTxMsJPT+q/JDBKYuPr073iblJl5S+/so+Ia8NHDJfp5I4q4hKCRMYns4Aur3csU
kGJiSa+YVx5dkh6FSYBri1yWdu2BMIPVBwR/q4lGv80c64U04PWiVp6Z8SR56PS0
PJOZYKtgpZ0GJ7ghrv+74HitYYBXBsYc8uP1mfKtuN7AQk7iO8+sttFAAdx5QhQJ
nn2wsXzbeFDtis3kSH5rjrjRsunTcPzbcf/YfQeBw+rCgAARNKSQTQCu7la161cA
OJ0bdOpSwHRZdMu4sqpjSUnim54i+6WQi10J3EFiULTRWkT1QLsXL+y/QO7jKWJa
MaBowvhD9Z4dqreMzFLCpBioJjX5acJhNWeReop3OFjiO2DI/T6sK57Lacqi5PBK
cFA7AhXOOSdymipReu4BHt/y
=bwyT
-----END PGP PUBLIC KEY BLOCK-----

View file

@ -1,3 +0,0 @@
[engine]
cgroup_manager="cgroupfs"
events_logger="file"

View file

@ -1,10 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os
import sys
# Load dangerzone module and resources from the source code tree # Load dangerzone module and resources from the source code tree
import os, sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.dangerzone_dev = True sys.dangerzone_dev = True

View file

@ -1,782 +0,0 @@
#!/usr/bin/env python3
import argparse
import hashlib
import os
import pathlib
import platform
import shutil
import subprocess
import sys
from datetime import date
DEFAULT_GUI = True
DEFAULT_USER = "user"
DEFAULT_DRY = False
DEFAULT_DEV = False
DEFAULT_SHOW_DOCKERFILE = False
# The Linux distributions that we currently support.
# FIXME: Add a version mapping to avoid mistakes.
# FIXME: Maybe create an enum for these values.
DISTROS = ["debian", "fedora", "ubuntu"]
CONTAINER_RUNTIMES = ["podman", "docker"]
IMAGES_REGISTRY = "ghcr.io/freedomofpress/"
IMAGE_NAME_BUILD_DEV_FMT = (
IMAGES_REGISTRY + "v2/dangerzone/build-dev/{distro}-{version}:{date}-{hash}"
)
IMAGE_NAME_BUILD_ENDUSER_FMT = (
IMAGES_REGISTRY + "v2/dangerzone/end-user/{distro}-{version}:{date}-{hash}"
)
EPILOG = """\
Examples:
Build a Dangerzone environment for development (build-dev) or testing (build) based on
Ubuntu 22.04:
env.py --distro ubuntu --version 22.04 build-dev
env.py --distro ubuntu --version 22.04 build
Inspect the Dockerfile for the environments:
env.py --distro ubuntu --version 22.04 build-dev --show-dockerfile
env.py --distro ubuntu --version 22.04 build --show-dockerfile
Run an interactive shell in the development or end-user environment:
env.py --distro ubuntu --version 22.04 run --dev bash
env.py --distro ubuntu --version 22.04 run bash
Run Dangerzone in the development environment:
env.py --distro ubuntu --version 22.04 run --dev bash
user@dangerzone-dev:~$ cd dangerzone/
user@dangerzone-dev:~$ poetry run ./dev_scripts/dangerzone
Run Dangerzone in the end-user environment:
env.py --distro ubuntu --version 22.04 run dangerzone
"""
# XXX: overcome the fact that ubuntu images (starting on 23.04) ship with the 'ubuntu'
# user by default https://bugs.launchpad.net/cloud-images/+bug/2005129
# Related issue https://github.com/freedomofpress/dangerzone/pull/461
DOCKERFILE_UBUNTU_REM_USER = r"""
RUN touch /var/mail/ubuntu && chown ubuntu /var/mail/ubuntu && userdel -r ubuntu
"""
# On Ubuntu Jammy, use a different conmon version, as acquired from our apt-tools-prod
# repo. For more details, read:
# https://github.com/freedomofpress/dangerzone/issues/685
DOCKERFILE_CONMON_UPDATE = r"""
RUN apt-get update \
&& apt-get install -y ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY apt-tools-prod.sources /etc/apt/sources.list.d/
COPY apt-tools-prod.pref /etc/apt/preferences.d/
"""
# FIXME: Do we really need the python3-venv packages?
DOCKERFILE_BUILD_DEV_DEBIAN_DEPS = r"""
ARG DEBIAN_FRONTEND=noninteractive
# NOTE: Podman has several recommended packages that are actually essential for rootless
# containers. However, certain Podman versions (e.g., in Debian Trixie) bring Systemd in
# as a recommended dependency. The latter is a cause for problems, so we prefer to
# install only a subset of the recommended Podman packages. See also:
# https://github.com/freedomofpress/dangerzone/issues/689
RUN apt-get update \
&& apt-get install -y --no-install-recommends podman uidmap slirp4netns \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update \
&& apt-get install -y passt || echo "Skipping installation of passt package" \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update \
&& apt-get install -y --no-install-recommends dh-python make build-essential \
git {qt_deps} pipx python3 python3-pip python3-venv dpkg-dev debhelper python3-setuptools \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
RUN pipx install poetry
RUN apt-get update \
&& apt-get install -y --no-install-recommends mupdf thunar \
&& rm -rf /var/lib/apt/lists/*
"""
# FIXME: Install Poetry on Fedora via package manager.
DOCKERFILE_BUILD_DEV_FEDORA_DEPS = r"""
RUN dnf install -y git rpm-build podman python3 python3-devel python3-poetry-core \
pipx make qt6-qtbase-gui gcc gcc-c++\
&& dnf clean all
# FIXME: Drop this fix after it's resolved upstream.
# See https://github.com/freedomofpress/dangerzone/issues/286#issuecomment-1347149783
RUN rpm --restore shadow-utils
RUN dnf install -y mupdf thunar && dnf clean all
"""
# The Dockerfile for building a development environment for Dangerzone. Parts of the
# Dockerfile will be populated during runtime.
DOCKERFILE_BUILD_DEV = r"""FROM {distro}:{version}
{install_deps}
#########################################
# Create a non-root user to run Dangerzone
RUN adduser user
# See https://github.com/freedomofpress/dangerzone/issues/286#issuecomment-1347149783
RUN echo user:2000:2000 > /etc/subuid
RUN echo user:2000:2000 > /etc/subgid
# XXX: We need the empty source folder, so that we can trick Poetry to create a
# link to the project's path. This way, we should be able to do `import
# dangerzone` from within the container.
RUN mkdir -p /home/user/dangerzone/dangerzone
RUN touch /home/user/dangerzone/dangerzone/__init__.py
USER user
WORKDIR /home/user
VOLUME /home/user/dangerzone
# Force Podman to use a specific configuration.
# See https://github.com/freedomofpress/dangerzone/issues/489
RUN mkdir -p /home/user/.config/containers
COPY storage.conf /home/user/.config/containers
# Install Poetry under ~/.local/bin.
# See https://github.com/freedomofpress/dangerzone/issues/351
# FIXME: pipx install poetry does not work for Ubuntu Focal.
ENV PATH="$PATH:/home/user/.local/bin"
RUN pipx install poetry
RUN pipx inject poetry poetry-plugin-export
COPY pyproject.toml poetry.lock /home/user/dangerzone/
RUN cd /home/user/dangerzone && poetry --no-ansi install
"""
DOCKERFILE_BUILD_DEBIAN_DEPS = r"""
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends mupdf thunar \
&& rm -rf /var/lib/apt/lists/*
"""
DOCKERFILE_BUILD_FEDORA_DEPS = r"""
RUN dnf install -y mupdf thunar && dnf clean all
# FIXME: Drop this fix after it's resolved upstream.
# See https://github.com/freedomofpress/dangerzone/issues/286#issuecomment-1347149783
RUN rpm --restore shadow-utils
"""
# The Dockerfile for building an environment with Dangerzone installed in it. Parts of
# the Dockerfile will be populated during runtime.
#
# FIXME: The fact that we remove the package does not reduce the image size. We need to
# flatten the image layers as well.
DOCKERFILE_BUILD = r"""FROM {distro}:{version}
{install_deps}
COPY {package} /tmp/{package}
RUN {install_cmd} /tmp/{package}
RUN rm /tmp/{package}
#########################################
# Create a non-root user to run Dangerzone
RUN adduser user
# See https://github.com/freedomofpress/dangerzone/issues/286#issuecomment-1347149783
RUN echo user:2000:2000 > /etc/subuid
RUN echo user:2000:2000 > /etc/subgid
USER user
WORKDIR /home/user
########################################
# Force Podman to use a specific configuration.
# See https://github.com/freedomofpress/dangerzone/issues/489
RUN mkdir -p /home/user/.config/containers
COPY storage.conf /home/user/.config/containers
"""
def run(*args):
"""Simple function that runs a command, validates it, and returns the output"""
return subprocess.run(
args, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
).stdout
def git_root():
"""Get the root directory of the Git repo."""
# FIXME: Use a Git Python binding for this.
# FIXME: Make this work if called outside the repo.
path = run("git", "rev-parse", "--show-toplevel").decode().strip("\n")
return pathlib.Path(path)
def user_data():
"""Get the user data dir in (which differs on different OSes)"""
home = pathlib.Path.home()
system = platform.system()
if system == "Windows":
return home / "AppData" / "Local"
elif system == "Linux":
return home / ".local" / "share"
elif system == "Darwin":
return home / "Library" / "Application Support"
def dz_dev_root():
"""Get the directory where we will store dangerzone-dev related files"""
return user_data() / "dangerzone-dev"
def distro_root(distro, version):
"""Get the root directory for the specific Linux environment."""
return dz_dev_root() / "envs" / distro / version
def distro_state(distro, version):
"""Get the directory where we will store the state for the distro."""
return distro_root(distro, version) / "state"
def distro_build(distro, version):
"""Get the directory where we will store the build files for the distro."""
return distro_root(distro, version) / "build"
def get_current_date():
return date.today().strftime("%Y-%m-%d")
def get_build_dir_sources(distro, version):
"""Return the files needed to build an image."""
sources = [
git_root() / "pyproject.toml",
git_root() / "poetry.lock",
git_root() / "dev_scripts" / "env.py",
git_root() / "dev_scripts" / "storage.conf",
git_root() / "dev_scripts" / "containers.conf",
]
if distro == "ubuntu" and version in ("22.04", "jammy"):
sources.extend(
[
git_root() / "dev_scripts" / "apt-tools-prod.pref",
git_root() / "dev_scripts" / "apt-tools-prod.sources",
]
)
return sources
def image_name_build_dev(distro, version):
"""Get the container image for the dev variant of a Dangerzone environment."""
hash = hash_files(get_build_dir_sources(distro, version))
return IMAGE_NAME_BUILD_DEV_FMT.format(
distro=distro, version=version, hash=hash, date=get_current_date()
)
def image_name_build_enduser(distro, version):
"""Get the container image for the Dangerzone end-user environment."""
hash = hash_files(get_files_in("install/linux", "debian"))
return IMAGE_NAME_BUILD_ENDUSER_FMT.format(
distro=distro, version=version, hash=hash, date=get_current_date()
)
def dz_version():
"""Get the current Dangerzone version."""
with open(git_root() / "share/version.txt") as f:
return f.read().strip()
def hash_files(file_paths: list[pathlib.Path]) -> str:
"""Returns the hash value of a list of files using the sha256 hashing algorithm."""
hash_obj = hashlib.new("sha256")
for path in file_paths:
with open(path, "rb") as file:
file_data = file.read()
hash_obj.update(file_data)
return hash_obj.hexdigest()
def get_files_in(*folders: list[str]) -> list[pathlib.Path]:
"""Return the list of all files present in the given folders"""
files = []
for folder in folders:
files.extend([p for p in (git_root() / folder).glob("**") if p.is_file()])
return files
class Env:
"""A class that implements actions on Dangerzone environments"""
def __init__(self, distro, version, runtime):
"""Initialize an Env class based on some common parameters."""
self.distro = distro
self.version = version
# NOTE: We change "bullseye" to "bullseye-backports", since it contains `pipx`,
# which is not available through the original repos.
if self.distro == "debian" and self.version in ("bullseye", "11"):
self.version = "bullseye-backports"
# Try to autodetect the runtime, if the user has not provided it.
podman_cmd = ["podman"]
docker_cmd = ["docker"]
if not runtime:
if shutil.which("podman"):
self.runtime = "podman"
self.runtime_cmd = podman_cmd
elif shutil.which("docker"):
self.runtime = "docker"
self.runtime_cmd = docker_cmd
else:
raise SystemError(
"You need either Podman or Docker installed to continue"
)
elif runtime == "podman":
self.runtime = "podman"
self.runtime_cmd = podman_cmd
elif runtime == "docker":
self.runtime = "docker"
self.runtime_cmd = docker_cmd
else:
raise RuntimeError(f"Unexpected runtime: {runtime}")
@classmethod
def from_args(cls, args):
"""Create an Env class from CLI arguments"""
return cls(distro=args.distro, version=args.version, runtime=args.runtime)
def find_dz_package(self, path, pattern):
"""Get the full path of the Dangerzone package in the specified dir.
There are times where we don't know the exact name of the Dangerzone package
that we've built, e.g., because its patch level may have changed.
Auto-detect the Dangerzone package based on a pattern that a user has provided,
and fail if there are none, or multiple matches. If there's a single match, then
return the full path for the package.
"""
matches = list(path.glob(pattern))
if len(matches) == 0:
raise RuntimeError(
f"Could not find Dangerzone package '{pattern}' in '{path}'"
)
elif len(matches) > 1:
raise RuntimeError(
f"Found more than one matches for Dangerzone package '{pattern}' in"
f" '{path}'"
)
return matches[0]
def runtime_run(self, *args):
"""Run a command for a specific container runtime.
A user's environment may have more than one container runtime [1], e.g., Podman
or Docker. These two runtimes have the same interface, so we can use them
interchangeably.
This method expects a command to run, minus the "docker" / "podman" part. Since
the command can be any valid command, such as "run" or "build", we can't assume
anything about the standard streams, so we don't affect them at all.
[1]: Technically, a container runtime is a program that implements the Container
Runtime Interface. We overload this term here, in lieu of a better one.
"""
subprocess.run(self.runtime_cmd + list(args), check=True)
def run(
self, cmd, gui=DEFAULT_GUI, user=DEFAULT_USER, dry=DEFAULT_DRY, dev=DEFAULT_DEV
):
"""Run a command in a Dangerzone environment."""
# FIXME: Allow wiping the state of the distro before running the environment, to
# ensure reproducibility.
run_cmd = [
"run",
"--rm",
"-it",
"-v",
"/etc/localtime:/etc/localtime:ro",
# FIXME: Find a more secure invocation.
"--security-opt",
"seccomp=unconfined",
"--privileged",
]
# We need to retain our UID, because we are mounting the Dangerzone source to
# the container.
if self.runtime == "podman":
uidmaps = [
"--uidmap",
"1000:0:1",
"--uidmap",
"0:1:1000",
"--uidmap",
"1001:1001:64536",
]
gidmaps = [
"--gidmap",
"1000:0:1",
"--gidmap",
"0:1:1000",
"--gidmap",
"1001:1001:64536",
]
run_cmd += uidmaps + gidmaps
# Compute container runtime arguments for GUI purposes.
if gui:
# Detect X11 display server connection settings.
env_display = os.environ.get("DISPLAY")
env_xauthority = os.environ.get("XAUTHORITY")
run_cmd += [
"-e",
f"DISPLAY={env_display}",
"-v",
"/tmp/.X11-unix:/tmp/.X11-unix:ro",
]
if env_xauthority:
run_cmd += [
"-e",
f"XAUTHORITY={env_xauthority}",
"-v",
f"{env_xauthority}:{env_xauthority}:ro",
]
# FIXME: Detect Wayland connection settings. This requires some extra
# settings, as we can see in this link:
#
# https://github.com/mviereck/x11docker/wiki/How-to-provide-Wayland-socket-to-docker-container
# Mount the source and the state of the distro into the container
dz_src = git_root()
dist_state = distro_state(self.distro, self.version)
run_cmd += [
"-v",
f"{dz_src}:/home/user/dangerzone",
"-v",
f"{dist_state}/containers:/home/user/.local/share/containers",
"-v",
f"{dist_state}/.bash_history:/home/user/.bash_history",
]
run_cmd += ["-u", user]
# Select the proper container image based on whether the user wants to run the
# command in a dev or end-user environment.
if dev:
run_cmd += [
"--hostname",
"dangerzone-dev",
image_name_build_dev(self.distro, self.version),
]
else:
run_cmd += [
"--hostname",
"dangerzone",
image_name_build_enduser(self.distro, self.version),
]
run_cmd += cmd
# If the user has asked to perform a dry-run, then print the command that the
# script would use internally.
if dry:
print(" ".join(self.runtime_cmd + list(run_cmd)))
return
dist_state.mkdir(parents=True, exist_ok=True)
(dist_state / "containers").mkdir(exist_ok=True)
(dist_state / ".bash_history").touch(exist_ok=True)
self.runtime_run(*run_cmd)
def pull_image_from_registry(self, image):
try:
subprocess.run(self.runtime_cmd + ["pull", image], check=True)
return True
except subprocess.CalledProcessError:
# Do not log an error here, we are just checking if the image exists
# on the registry.
return False
def push_image_to_registry(self, image):
try:
subprocess.run(self.runtime_cmd + ["push", image], check=True)
return True
except subprocess.CalledProcessError as e:
print("An error occured when pulling the image: ", e)
return False
def build_dev(self, show_dockerfile=DEFAULT_SHOW_DOCKERFILE, sync=False):
"""Build a Linux environment and install tools for Dangerzone development."""
image = image_name_build_dev(self.distro, self.version)
if sync and self.pull_image_from_registry(image):
print("Image has been pulled from the registry, no need to build it.")
return
elif sync:
print("Image label not in registry, building it")
if self.distro == "fedora":
install_deps = DOCKERFILE_BUILD_DEV_FEDORA_DEPS
else:
# Use Qt6 in all of our Linux dev environments, and add a missing
# libxcb-cursor0 dependency
#
# See https://github.com/freedomofpress/dangerzone/issues/482
qt_deps = "libqt6gui6 libxcb-cursor0"
install_deps = DOCKERFILE_BUILD_DEV_DEBIAN_DEPS
if self.distro == "ubuntu" and self.version in ("22.04", "jammy"):
# Ubuntu Jammy misses a dependency to `libxkbcommon-x11-0`, which we can
# install indirectly via `qt6-qpa-plugins`.
qt_deps += " qt6-qpa-plugins"
# Ubuntu Jammy requires a more up-to-date conmon package
# (see https://github.com/freedomofpress/dangerzone/issues/685)
install_deps = (
DOCKERFILE_CONMON_UPDATE + DOCKERFILE_BUILD_DEV_DEBIAN_DEPS
)
elif self.distro == "ubuntu" and self.version in (
"24.04",
"noble",
"24.10",
"ocular",
"25.04",
"plucky",
):
install_deps = (
DOCKERFILE_UBUNTU_REM_USER + DOCKERFILE_BUILD_DEV_DEBIAN_DEPS
)
elif self.distro == "debian" and self.version in ("bullseye-backports",):
# Debian Bullseye misses a dependency to libgl1.
qt_deps += " libgl1"
install_deps = install_deps.format(qt_deps=qt_deps)
dockerfile = DOCKERFILE_BUILD_DEV.format(
distro=self.distro, version=self.version, install_deps=install_deps
)
if show_dockerfile:
print(dockerfile)
return
build_dir = distro_build(self.distro, self.version)
os.makedirs(build_dir, exist_ok=True)
# Populate the build context.
for source in get_build_dir_sources(self.distro, self.version):
shutil.copy(source, build_dir)
with open(build_dir / "Dockerfile", mode="w") as f:
f.write(dockerfile)
self.runtime_run("build", "-t", image, build_dir)
if sync:
if not self.push_image_to_registry(image):
print("An error occured while trying to push to the container registry")
def build(
self,
show_dockerfile=DEFAULT_SHOW_DOCKERFILE,
):
"""Build a Linux environment and install Dangerzone in it."""
build_dir = distro_build(self.distro, self.version)
os.makedirs(build_dir, exist_ok=True)
version = dz_version()
if self.distro == "fedora":
install_deps = DOCKERFILE_BUILD_FEDORA_DEPS
package_pattern = f"dangerzone-{version}-*.fc{self.version}.x86_64.rpm"
package_src = self.find_dz_package(git_root() / "dist", package_pattern)
package = package_src.name
package_dst = build_dir / package
install_cmd = "dnf install -y"
else:
install_deps = DOCKERFILE_BUILD_DEBIAN_DEPS
if self.distro == "ubuntu" and self.version in ("22.04", "jammy"):
# Ubuntu Jammy requires a more up-to-date conmon
# package (see https://github.com/freedomofpress/dangerzone/issues/685)
install_deps = DOCKERFILE_CONMON_UPDATE + DOCKERFILE_BUILD_DEBIAN_DEPS
elif self.distro == "ubuntu" and self.version in (
"24.04",
"noble",
"24.10",
"ocular",
"25.04",
"plucky",
):
install_deps = DOCKERFILE_UBUNTU_REM_USER + DOCKERFILE_BUILD_DEBIAN_DEPS
package_pattern = f"dangerzone_{version}-*_*.deb"
package_src = self.find_dz_package(git_root() / "deb_dist", package_pattern)
package = package_src.name
package_dst = build_dir / package
install_cmd = "apt-get update && apt-get install -y"
dockerfile = DOCKERFILE_BUILD.format(
distro=self.distro,
version=self.version,
install_cmd=install_cmd,
package=package,
install_deps=install_deps,
)
if show_dockerfile:
print(dockerfile)
return
# Populate the build context.
shutil.copy(package_src, package_dst)
shutil.copy(git_root() / "dev_scripts" / "storage.conf", build_dir)
shutil.copy(git_root() / "dev_scripts" / "containers.conf", build_dir)
if self.distro == "ubuntu" and self.version in ("22.04", "jammy"):
shutil.copy(git_root() / "dev_scripts" / "apt-tools-prod.pref", build_dir)
shutil.copy(
git_root() / "dev_scripts" / "apt-tools-prod.sources", build_dir
)
with open(build_dir / "Dockerfile", mode="w") as f:
f.write(dockerfile)
image = image_name_build_enduser(self.distro, self.version)
self.runtime_run("build", "-t", image, build_dir)
def env_run(args):
"""Invoke the 'run' command based on the CLI args."""
if not args.command:
print("Please provide a command for the environment")
sys.exit(1)
env = Env.from_args(args)
return env.run(
args.command, gui=args.gui, user=args.user, dry=args.dry, dev=args.dev
)
def env_build_dev(args):
"""Invoke the 'build-dev' command based on the CLI args."""
env = Env.from_args(args)
return env.build_dev(show_dockerfile=args.show_dockerfile, sync=args.sync)
def env_build(args):
"""Invoke the 'build' command based on the CLI args."""
env = Env.from_args(args)
return env.build(
show_dockerfile=args.show_dockerfile,
)
def parse_args():
parser = argparse.ArgumentParser(
prog=sys.argv[0],
description="Dev script for handling Dangerzone environments",
epilog=EPILOG,
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
"--distro",
choices=DISTROS,
required=True,
help="The name of the Linux distro",
)
parser.add_argument(
"--version",
required=True,
help="The version of the Linux distro",
)
parser.add_argument(
"--runtime",
choices=CONTAINER_RUNTIMES,
help="The name of the container runtime",
)
subparsers = parser.add_subparsers(help="Available subcommands")
subparsers.required = True
# Run a command in an environment.
parser_run = subparsers.add_parser(
"run", help="Run a command in a Dangerzone environment"
)
parser_run.set_defaults(func=env_run)
parser_run.add_argument(
"--no-gui",
default=DEFAULT_GUI,
action="store_false",
dest="gui",
help="Run command with GUI support",
)
parser_run.add_argument(
"--user",
"-u",
default=DEFAULT_USER,
help="Run command as user USER",
)
parser_run.add_argument(
"--dry",
default=DEFAULT_DRY,
action="store_true",
help="Do not run the command, just print it with the container invocation",
)
parser_run.add_argument(
"--dev",
default=DEFAULT_DEV,
action="store_true",
help="Run the command into the dev variant of the Dangerzone environment",
)
parser_run.add_argument(
"command",
nargs=argparse.REMAINDER,
help="Run command COMMAND in the Dangerzone environment",
)
# Build a development variant of a Dangerzone environment.
parser_build_dev = subparsers.add_parser(
"build-dev",
help="Build a Linux environment and install tools for Dangerzone development",
)
parser_build_dev.set_defaults(func=env_build_dev)
parser_build_dev.add_argument(
"--show-dockerfile",
default=DEFAULT_SHOW_DOCKERFILE,
action="store_true",
help="Do not build, only show the Dockerfile",
)
parser_build_dev.add_argument(
"--sync",
default=False,
action="store_true",
help="Attempt to pull the image, build it if not found and push it to the container registry",
)
# Build a development variant of a Dangerzone environment.
parser_build = subparsers.add_parser(
"build",
help="Build a Linux environment and install Dangerzone",
)
parser_build.set_defaults(func=env_build)
parser_build.add_argument(
"--show-dockerfile",
default=DEFAULT_SHOW_DOCKERFILE,
action="store_true",
help="Do not build, only show the Dockerfile",
)
return parser.parse_args()
def main():
args = parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,254 +0,0 @@
#!/usr/bin/env python3
import argparse
import asyncio
import re
import sys
from datetime import datetime
from typing import Dict, List, Optional, Tuple
import httpx
REPOSITORY = "https://github.com/freedomofpress/dangerzone/"
TEMPLATE = "- {title} ([#{number}]({url}))"
def parse_version(version: str) -> Tuple[int, int]:
"""Extract major.minor from version string, ignoring patch"""
match = re.match(r"v?(\d+)\.(\d+)", version)
if not match:
raise ValueError(f"Invalid version format: {version}")
return (int(match.group(1)), int(match.group(2)))
async def get_last_minor_release(
client: httpx.AsyncClient, owner: str, repo: str
) -> Optional[str]:
"""Get the latest minor release date (ignoring patches)"""
response = await client.get(f"https://api.github.com/repos/{owner}/{repo}/releases")
response.raise_for_status()
releases = response.json()
if not releases:
return None
# Get the latest minor version by comparing major.minor numbers
current_version = parse_version(releases[0]["tag_name"])
latest_date = None
for release in releases:
try:
version = parse_version(release["tag_name"])
if version < current_version:
latest_date = release["published_at"]
break
except ValueError:
continue
return latest_date
async def get_issue_details(
client: httpx.AsyncClient, owner: str, repo: str, issue_number: int
) -> Optional[dict]:
"""Get issue title and number if it exists"""
response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}"
)
if response.is_success:
data = response.json()
return {
"title": data["title"],
"number": data["number"],
"url": data["html_url"],
}
return None
def extract_issue_number(pr_body: Optional[str]) -> Optional[int]:
"""Extract issue number from PR body looking for common formats like 'Fixes #123' or 'Closes #123'"""
if not pr_body:
return None
patterns = [
r"(?:closes|fixes|resolves)\s*#(\d+)",
r"(?:close|fix|resolve)\s*#(\d+)",
]
for pattern in patterns:
match = re.search(pattern, pr_body.lower())
if match:
return int(match.group(1))
return None
async def verify_commit_in_master(
client: httpx.AsyncClient, owner: str, repo: str, commit_id: str
) -> bool:
"""Verify if a commit exists in master"""
response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/commits/{commit_id}"
)
return response.is_success and response.json().get("commit") is not None
async def process_issue_events(
client: httpx.AsyncClient, owner: str, repo: str, issue: Dict
) -> Optional[Dict]:
"""Process events for a single issue"""
events_response = await client.get(f"{issue['url']}/events")
if not events_response.is_success:
return None
for event in events_response.json():
if event["event"] == "closed" and event.get("commit_id"):
if await verify_commit_in_master(client, owner, repo, event["commit_id"]):
return {
"title": issue["title"],
"number": issue["number"],
"url": issue["html_url"],
}
return None
async def get_closed_issues(
client: httpx.AsyncClient, owner: str, repo: str, since: str
) -> List[Dict]:
"""Get issues closed by commits to master since the given date"""
response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/issues",
params={
"state": "closed",
"sort": "updated",
"direction": "desc",
"since": since,
"per_page": 100,
},
)
response.raise_for_status()
tasks = []
since_date = datetime.strptime(since, "%Y-%m-%dT%H:%M:%SZ")
for issue in response.json():
if "pull_request" in issue:
continue
closed_at = datetime.strptime(issue["closed_at"], "%Y-%m-%dT%H:%M:%SZ")
if closed_at <= since_date:
continue
tasks.append(process_issue_events(client, owner, repo, issue))
results = await asyncio.gather(*tasks)
return [r for r in results if r is not None]
async def process_pull_request(
client: httpx.AsyncClient,
owner: str,
repo: str,
pr: Dict,
closed_issues: List[Dict],
) -> Optional[str]:
"""Process a single pull request"""
issue_number = extract_issue_number(pr.get("body"))
if issue_number:
issue = await get_issue_details(client, owner, repo, issue_number)
if issue:
if not any(i["number"] == issue["number"] for i in closed_issues):
return TEMPLATE.format(**issue)
return None
return TEMPLATE.format(title=pr["title"], number=pr["number"], url=pr["html_url"])
async def get_changes_since_last_release(
owner: str, repo: str, token: Optional[str] = None
) -> List[str]:
headers = {
"Accept": "application/vnd.github.v3+json",
}
if token:
headers["Authorization"] = f"token {token}"
else:
print(
"Warning: No token provided. API rate limiting may occur.", file=sys.stderr
)
async with httpx.AsyncClient(headers=headers, timeout=30.0) as client:
# Get the date of last minor release
since = await get_last_minor_release(client, owner, repo)
if not since:
return []
changes = []
# Get issues closed by commits to master
closed_issues = await get_closed_issues(client, owner, repo, since)
changes.extend([TEMPLATE.format(**issue) for issue in closed_issues])
# Get merged PRs
response = await client.get(
f"https://api.github.com/repos/{owner}/{repo}/pulls",
params={
"state": "closed",
"sort": "updated",
"direction": "desc",
"per_page": 100,
},
)
response.raise_for_status()
# Process PRs in parallel
pr_tasks = []
for pr in response.json():
if not pr["merged_at"]:
continue
if since and pr["merged_at"] <= since:
break
pr_tasks.append(
process_pull_request(client, owner, repo, pr, closed_issues)
)
pr_results = await asyncio.gather(*pr_tasks)
changes.extend([r for r in pr_results if r is not None])
return changes
async def main_async():
parser = argparse.ArgumentParser(description="Generate release notes from GitHub")
parser.add_argument("--token", "-t", help="the file path to the GitHub API token")
args = parser.parse_args()
token = None
if args.token:
with open(args.token) as f:
token = f.read().strip()
try:
url_path = REPOSITORY.rstrip("/").split("github.com/")[1]
owner, repo = url_path.split("/")[-2:]
except (ValueError, IndexError):
print("Error: Invalid GitHub URL", file=sys.stderr)
sys.exit(1)
try:
notes = await get_changes_since_last_release(owner, repo, token)
print("\n".join(notes))
except httpx.HTTPError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def main():
asyncio.run(main_async())
if __name__ == "__main__":
main()

View file

@ -1,67 +0,0 @@
#!/usr/bin/env python3
import pathlib
import subprocess
RELEASE_FILE = "RELEASE.md"
QA_FILE = "QA.md"
def git_root():
"""Get the root directory of the Git repo."""
# FIXME: Use a Git Python binding for this.
# FIXME: Make this work if called outside the repo.
path = (
subprocess.run(
["git", "rev-parse", "--show-toplevel"],
check=True,
stdout=subprocess.PIPE,
)
.stdout.decode()
.strip("\n")
)
return pathlib.Path(path)
def extract_checkboxes(filename):
headers = []
result = []
with open(filename, "r") as f:
lines = f.readlines()
current_level = 0
for line in lines:
line = line.rstrip()
# If it's a header, store it
if line.startswith("#"):
# Count number of # to determine header level
level = len(line) - len(line.lstrip("#"))
if level < current_level or not current_level:
headers.extend(["", line, ""])
current_level = level
elif level > current_level:
continue
else:
headers = ["", line, ""]
# If it's a checkbox
elif "- [ ]" in line or "- [x]" in line or "- [X]" in line:
# Print the last header if we haven't already
if headers:
result.extend(headers)
headers = []
current_level = 0
# If this is the "Do the QA tasks" line, recursively get QA tasks
if "Do the QA tasks" in line:
result.append(line)
qa_tasks = extract_checkboxes(git_root() / QA_FILE)
result.append(qa_tasks)
else:
result.append(line)
return "\n".join(result)
if __name__ == "__main__":
print(extract_checkboxes(git_root() / RELEASE_FILE))

File diff suppressed because it is too large Load diff

View file

@ -1,680 +0,0 @@
#!/usr/bin/env python3
import argparse
import datetime
import hashlib
import json
import logging
import os
import pprint
import shlex
import shutil
import subprocess
import sys
import tarfile
from pathlib import Path
logger = logging.getLogger(__name__)
MEDIA_TYPE_INDEX_V1_JSON = "application/vnd.oci.image.index.v1+json"
MEDIA_TYPE_MANIFEST_V1_JSON = "application/vnd.oci.image.manifest.v1+json"
ENV_RUNTIME = "REPRO_RUNTIME"
ENV_DATETIME = "REPRO_DATETIME"
ENV_SDE = "REPRO_SOURCE_DATE_EPOCH"
ENV_CACHE = "REPRO_CACHE"
ENV_BUILDKIT = "REPRO_BUILDKIT_IMAGE"
ENV_ROOTLESS = "REPRO_ROOTLESS"
DEFAULT_BUILDKIT_IMAGE = "moby/buildkit:v0.19.0@sha256:14aa1b4dd92ea0a4cd03a54d0c6079046ea98cd0c0ae6176bdd7036ba370cbbe"
DEFAULT_BUILDKIT_IMAGE_ROOTLESS = "moby/buildkit:v0.19.0-rootless@sha256:e901cffdad753892a7c3afb8b9972549fca02c73888cf340c91ed801fdd96d71"
MSG_BUILD_CTX = """Build environment:
- Container runtime: {runtime}
- BuildKit image: {buildkit_image}
- Rootless support: {rootless}
- Caching enabled: {use_cache}
- Build context: {context}
- Dockerfile: {dockerfile}
- Output: {output}
Build parameters:
- SOURCE_DATE_EPOCH: {sde}
- Build args: {build_args}
- Tag: {tag}
- Platform: {platform}
Podman-only arguments:
- BuildKit arguments: {buildkit_args}
Docker-only arguments:
- Docker Buildx arguments: {buildx_args}
"""
def pretty_error(obj: dict, msg: str):
raise Exception(f"{msg}\n{pprint.pprint(obj)}")
def get_key(obj: dict, key: str) -> object:
if key not in obj:
pretty_error(f"Could not find key '{key}' in the dictionary:", obj)
return obj[key]
def run(cmd, dry=False, check=True):
action = "Would have run" if dry else "Running"
logger.debug(f"{action}: {shlex.join(cmd)}")
if not dry:
subprocess.run(cmd, check=check)
def snip_contents(contents: str, num: int) -> str:
contents = contents.replace("\n", "")
if len(contents) > num:
return (
contents[:num]
+ f" [... {len(contents) - num} characters omitted."
+ " Pass --show-contents to print them in their entirety]"
)
return contents
def detect_container_runtime() -> str:
"""Auto-detect the installed container runtime in the system."""
if shutil.which("docker"):
return "docker"
elif shutil.which("podman"):
return "podman"
else:
return None
def parse_runtime(args) -> str:
if args.runtime is not None:
return args.runtime
runtime = os.environ.get(ENV_RUNTIME)
if runtime is None:
raise RuntimeError("No container runtime detected in your system")
if runtime not in ("docker", "podman"):
raise RuntimeError(
"Only 'docker' or 'podman' container runtimes"
" are currently supported by this script"
)
def parse_use_cache(args) -> bool:
if args.no_cache:
return False
return bool(int(os.environ.get(ENV_CACHE, "1")))
def parse_rootless(args, runtime: str) -> bool:
rootless = args.rootless or bool(int(os.environ.get(ENV_ROOTLESS, "0")))
if runtime != "podman" and rootless:
raise RuntimeError("Rootless mode is only supported with Podman runtime")
return rootless
def parse_sde(args) -> str:
sde = os.environ.get(ENV_SDE, args.source_date_epoch)
dt = os.environ.get(ENV_DATETIME, args.datetime)
if (sde is not None and dt is not None) or (sde is None and dt is None):
raise RuntimeError("You need to pass either a source date epoch or a datetime")
if sde is not None:
return str(sde)
if dt is not None:
d = datetime.datetime.fromisoformat(dt)
# If the datetime is naive, assume its timezone is UTC. The check is
# taken from:
# https://docs.python.org/3/library/datetime.html#determining-if-an-object-is-aware-or-naive
if d.tzinfo is None or d.tzinfo.utcoffset(d) is None:
d = d.replace(tzinfo=datetime.timezone.utc)
return int(d.timestamp())
def parse_buildkit_image(args, rootless: bool, runtime: str) -> str:
default = DEFAULT_BUILDKIT_IMAGE_ROOTLESS if rootless else DEFAULT_BUILDKIT_IMAGE
img = args.buildkit_image or os.environ.get(ENV_BUILDKIT, default)
if runtime == "podman" and not img.startswith("docker.io/"):
img = "docker.io/" + img
return img
def parse_build_args(args) -> str:
return args.build_arg or []
def parse_buildkit_args(args, runtime: str) -> str:
if not args.buildkit_args:
return []
if runtime != "podman":
raise RuntimeError("Cannot specify BuildKit arguments using the Podman runtime")
return shlex.split(args.buildkit_args)
def parse_buildx_args(args, runtime: str) -> str:
if not args.buildx_args:
return []
if runtime != "docker":
raise RuntimeError(
"Cannot specify Docker Buildx arguments using the Podman runtime"
)
return shlex.split(args.buildx_args)
def parse_image_digest(args) -> str | None:
if not args.expected_image_digest:
return None
parsed = args.expected_image_digest.split(":", 1)
if len(parsed) == 1:
return parsed[0]
else:
return parsed[1]
def parse_path(path: str | None) -> str | None:
return path and str(Path(path).absolute())
##########################
# OCI parsing logic
#
# Compatible with:
# * https://github.com/opencontainers/image-spec/blob/main/image-layout.md
def oci_print_info(parsed: dict, full: bool) -> None:
print(f"The OCI tarball contains an index and {len(parsed) - 1} manifest(s):")
print()
print(f"Image digest: {parsed[1]['digest']}")
for i, info in enumerate(parsed):
print()
if i == 0:
print(f"Index ({info['path']}):")
else:
print(f"Manifest {i} ({info['path']}):")
print(f" Digest: {info['digest']}")
print(f" Media type: {info['media_type']}")
print(f" Platform: {info['platform'] or '-'}")
contents = info["contents"] if full else snip_contents(info["contents"], 600)
print(f" Contents: {contents}")
print()
def oci_normalize_path(path):
if path.startswith("sha256:"):
hash_algo, checksum = path.split(":")
path = f"blobs/{hash_algo}/{checksum}"
return path
def oci_get_file_from_tarball(tar: tarfile.TarFile, path: str) -> dict:
"""Get file from an OCI tarball.
If the filename cannot be found, search again by prefixing it with "./", since we
have encountered path names in OCI tarballs prefixed with "./".
"""
try:
return tar.extractfile(path).read().decode()
except KeyError:
if not path.startswith("./") and not path.startswith("/"):
path = "./" + path
try:
return tar.extractfile(path).read().decode()
except KeyError:
# Do not raise here, so that we can raise the original exception below.
pass
raise
def oci_parse_manifest(tar: tarfile.TarFile, path: str, platform: dict | None) -> dict:
"""Parse manifest information in JSON format.
Interestingly, the platform info for a manifest is not included in the
manifest itself, but in the descriptor that points to it. So, we have to
carry it from the previous manifest and include in the info here.
"""
path = oci_normalize_path(path)
contents = oci_get_file_from_tarball(tar, path)
digest = "sha256:" + hashlib.sha256(contents.encode()).hexdigest()
contents_dict = json.loads(contents)
media_type = get_key(contents_dict, "mediaType")
manifests = contents_dict.get("manifests", [])
if platform:
os = get_key(platform, "os")
arch = get_key(platform, "architecture")
platform = f"{os}/{arch}"
return {
"path": path,
"contents": contents,
"digest": digest,
"media_type": media_type,
"platform": platform,
"manifests": manifests,
}
def oci_parse_manifests_dfs(
tar: tarfile.TarFile, path: str, parsed: list, platform: dict | None = None
) -> None:
info = oci_parse_manifest(tar, path, platform)
parsed.append(info)
for m in info["manifests"]:
oci_parse_manifests_dfs(tar, m["digest"], parsed, m.get("platform"))
def oci_parse_tarball(path: Path) -> dict:
parsed = []
with tarfile.TarFile.open(path) as tar:
oci_parse_manifests_dfs(tar, "index.json", parsed)
return parsed
##########################
# Image building logic
def podman_build(
context: str,
dockerfile: str | None,
tag: str | None,
buildkit_image: str,
sde: int,
rootless: bool,
use_cache: bool,
output: Path,
build_args: list,
platform: str,
buildkit_args: list,
dry: bool,
):
rootless_args = []
rootful_args = []
if rootless:
rootless_args = [
"--userns",
"keep-id:uid=1000,gid=1000",
"--security-opt",
"seccomp=unconfined",
"--security-opt",
"apparmor=unconfined",
"-e",
"BUILDKITD_FLAGS=--oci-worker-no-process-sandbox",
]
else:
rootful_args = ["--privileged"]
dockerfile_args_podman = []
dockerfile_args_buildkit = []
if dockerfile:
dockerfile_args_podman = ["-v", f"{dockerfile}:/tmp/Dockerfile"]
dockerfile_args_buildkit = ["--local", "dockerfile=/tmp"]
else:
dockerfile_args_buildkit = ["--local", "dockerfile=/tmp/work"]
tag_args = f",name={tag}" if tag else ""
cache_args = []
if use_cache:
cache_args = [
"--export-cache",
"type=local,mode=max,dest=/tmp/cache",
"--import-cache",
"type=local,src=/tmp/cache",
]
_build_args = []
for arg in build_args:
_build_args.append("--opt")
_build_args.append(f"build-arg:{arg}")
platform_args = ["--opt", f"platform={platform}"] if platform else []
cmd = [
"podman",
"run",
"-it",
"--rm",
"-v",
"buildkit_cache:/tmp/cache",
"-v",
f"{output.parent}:/tmp/image",
"-v",
f"{context}:/tmp/work",
"--entrypoint",
"buildctl-daemonless.sh",
*rootless_args,
*rootful_args,
*dockerfile_args_podman,
buildkit_image,
"build",
"--frontend",
"dockerfile.v0",
"--local",
"context=/tmp/work",
"--opt",
f"build-arg:SOURCE_DATE_EPOCH={sde}",
*_build_args,
"--output",
f"type=docker,dest=/tmp/image/{output.name},rewrite-timestamp=true{tag_args}",
*cache_args,
*dockerfile_args_buildkit,
*platform_args,
*buildkit_args,
]
run(cmd, dry)
def docker_build(
context: str,
dockerfile: str | None,
tag: str | None,
buildkit_image: str,
sde: int,
use_cache: bool,
output: Path,
build_args: list,
platform: str,
buildx_args: list,
dry: bool,
):
builder_id = hashlib.sha256(buildkit_image.encode()).hexdigest()
builder_name = f"repro-build-{builder_id}"
tag_args = ["-t", tag] if tag else []
cache_args = [] if use_cache else ["--no-cache", "--pull"]
cmd = [
"docker",
"buildx",
"create",
"--name",
builder_name,
"--driver-opt",
f"image={buildkit_image}",
]
run(cmd, dry, check=False)
dockerfile_args = ["-f", dockerfile] if dockerfile else []
_build_args = []
for arg in build_args:
_build_args.append("--build-arg")
_build_args.append(arg)
platform_args = ["--platform", platform] if platform else []
cmd = [
"docker",
"buildx",
"--builder",
builder_name,
"build",
"--build-arg",
f"SOURCE_DATE_EPOCH={sde}",
*_build_args,
"--provenance",
"false",
"--output",
f"type=docker,dest={output},rewrite-timestamp=true",
*cache_args,
*tag_args,
*dockerfile_args,
*platform_args,
*buildx_args,
context,
]
run(cmd, dry)
##########################
# Command logic
def build(args):
runtime = parse_runtime(args)
use_cache = parse_use_cache(args)
sde = parse_sde(args)
rootless = parse_rootless(args, runtime)
buildkit_image = parse_buildkit_image(args, rootless, runtime)
build_args = parse_build_args(args)
platform = args.platform
buildkit_args = parse_buildkit_args(args, runtime)
buildx_args = parse_buildx_args(args, runtime)
tag = args.tag
dockerfile = parse_path(args.file)
output = Path(parse_path(args.output))
dry = args.dry
context = parse_path(args.context)
logger.info(
MSG_BUILD_CTX.format(
runtime=runtime,
buildkit_image=buildkit_image,
sde=sde,
rootless=rootless,
use_cache=use_cache,
context=context,
dockerfile=dockerfile or "(not provided)",
tag=tag or "(not provided)",
output=output,
build_args=",".join(build_args) or "(not provided)",
platform=platform or "(default)",
buildkit_args=" ".join(buildkit_args) or "(not provided)",
buildx_args=" ".join(buildx_args) or "(not provided)",
)
)
try:
if runtime == "docker":
docker_build(
context,
dockerfile,
tag,
buildkit_image,
sde,
use_cache,
output,
build_args,
platform,
buildx_args,
dry,
)
else:
podman_build(
context,
dockerfile,
tag,
buildkit_image,
sde,
rootless,
use_cache,
output,
build_args,
platform,
buildkit_args,
dry,
)
except subprocess.CalledProcessError as e:
logger.error(f"Failed with {e.returncode}")
sys.exit(e.returncode)
def analyze(args) -> None:
expected_image_digest = parse_image_digest(args)
tarball_path = Path(args.tarball)
parsed = oci_parse_tarball(tarball_path)
oci_print_info(parsed, args.show_contents)
if expected_image_digest:
cur_digest = parsed[1]["digest"].split(":")[1]
if cur_digest != expected_image_digest:
raise Exception(
f"The image does not have the expected digest: {cur_digest} != {expected_image_digest}"
)
print(f"✅ Image digest matches {expected_image_digest}")
def define_build_cmd_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--runtime",
choices=["docker", "podman"],
default=detect_container_runtime(),
help="The container runtime for building the image (default: %(default)s)",
)
parser.add_argument(
"--datetime",
metavar="YYYY-MM-DD",
default=None,
help=(
"Provide a date and (optionally) a time in ISO format, which will"
" be used as the timestamp of the image layers"
),
)
parser.add_argument(
"--buildkit-image",
metavar="NAME:TAG@DIGEST",
default=None,
help=(
"The BuildKit container image which will be used for building the"
" reproducible container image. Make sure to pass the '-rootless'"
" variant if you are using rootless Podman"
" (default: docker.io/moby/buildkit:v0.19.0)"
),
)
parser.add_argument(
"--source-date-epoch",
"--sde",
metavar="SECONDS",
type=int,
default=None,
help="Provide a Unix timestamp for the image layers",
)
parser.add_argument(
"--no-cache",
default=False,
action="store_true",
help="Do not use existing cached images for the container build. Build from the start with a new set of cached layers.",
)
parser.add_argument(
"--rootless",
default=False,
action="store_true",
help="Run BuildKit in rootless mode (Podman only)",
)
parser.add_argument(
"-f",
"--file",
metavar="FILE",
default=None,
help="Pathname of a Dockerfile",
)
parser.add_argument(
"-o",
"--output",
metavar="FILE",
default=Path.cwd() / "image.tar",
help="Path to save OCI tarball (default: %(default)s)",
)
parser.add_argument(
"-t",
"--tag",
metavar="TAG",
default=None,
help="Tag the built image with the name %(metavar)s",
)
parser.add_argument(
"--build-arg",
metavar="ARG=VALUE",
action="append",
default=None,
help="Set build-time variables",
)
parser.add_argument(
"--platform",
metavar="PLAT1,PLAT2",
default=None,
help="Set platform for the image",
)
parser.add_argument(
"--buildkit-args",
metavar="'ARG1 ARG2'",
default=None,
help="Extra arguments for BuildKit (Podman only)",
)
parser.add_argument(
"--buildx-args",
metavar="'ARG1 ARG2'",
default=None,
help="Extra arguments for Docker Buildx (Docker only)",
)
parser.add_argument(
"--dry",
default=False,
action="store_true",
help="Do not run any commands, just print what would happen",
)
parser.add_argument(
"context",
metavar="CONTEXT",
help="Path to the build context",
)
def parse_args() -> dict:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", help="Available commands")
build_parser = subparsers.add_parser("build", help="Perform a build operation")
build_parser.set_defaults(func=build)
define_build_cmd_args(build_parser)
analyze_parser = subparsers.add_parser("analyze", help="Analyze an OCI tarball")
analyze_parser.set_defaults(func=analyze)
analyze_parser.add_argument(
"tarball",
metavar="FILE",
help="Path to OCI image in .tar format",
)
analyze_parser.add_argument(
"--expected-image-digest",
metavar="DIGEST",
default=None,
help="The expected digest for the provided image",
)
analyze_parser.add_argument(
"--show-contents",
default=False,
action="store_true",
help="Show full file contents",
)
return parser.parse_args()
def main() -> None:
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
args = parse_args()
if not hasattr(args, "func"):
args.func = build
args.func(args)
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,115 +0,0 @@
#!/usr/bin/env python3
import argparse
import hashlib
import logging
import pathlib
import platform
import stat
import subprocess
import sys
import urllib.request
logger = logging.getLogger(__name__)
if platform.system() in ["Darwin", "Windows"]:
CONTAINER_RUNTIME = "docker"
elif platform.system() == "Linux":
CONTAINER_RUNTIME = "podman"
def run(*args):
"""Simple function that runs a command and checks the result."""
logger.debug(f"Running command: {' '.join(args)}")
return subprocess.run(args, check=True)
def build_image(
platform=None,
runtime=None,
cache=True,
date=None,
):
"""Build the Dangerzone container image with a special tag."""
platform_args = [] if not platform else ["--platform", platform]
runtime_args = [] if not runtime else ["--runtime", runtime]
cache_args = [] if cache else ["--use-cache", "no"]
date_args = [] if not date else ["--debian-archive-date", date]
run(
"python3",
"./install/common/build-image.py",
*platform_args,
*runtime_args,
*cache_args,
*date_args,
)
def parse_args():
parser = argparse.ArgumentParser(
prog=sys.argv[0],
description="Dev script for verifying container image reproducibility",
)
parser.add_argument(
"--platform",
default=None,
help=f"The platform for building the image (default: current platform)",
)
parser.add_argument(
"--runtime",
choices=["docker", "podman"],
default=CONTAINER_RUNTIME,
help=f"The container runtime for building the image (default: {CONTAINER_RUNTIME})",
)
parser.add_argument(
"--no-cache",
default=False,
action="store_true",
help=(
"Do not use existing cached images for the container build."
" Build from the start with a new set of cached layers."
),
)
parser.add_argument(
"--debian-archive-date",
default=None,
help="Use a specific Debian snapshot archive, by its date",
)
parser.add_argument(
"digest",
help="The digest of the image that you want to reproduce",
)
return parser.parse_args()
def main():
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
args = parse_args()
logger.info(f"Building container image")
build_image(
args.platform,
args.runtime,
not args.no_cache,
args.debian_archive_date,
)
logger.info(
f"Check that the reproduced image has the expected digest: {args.digest}"
)
run(
"./dev_scripts/repro-build.py",
"analyze",
"--show-contents",
"share/container.tar",
"--expected-image-digest",
args.digest,
)
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,131 +0,0 @@
#!/usr/bin/env python3
import argparse
import hashlib
import logging
import pathlib
import subprocess
import sys
log = logging.getLogger(__name__)
DZ_ASSETS = [
"container-{version}-i686.tar",
"container-{version}-arm64.tar",
"Dangerzone-{version}.msi",
"Dangerzone-{version}-arm64.dmg",
"Dangerzone-{version}-i686.dmg",
"dangerzone-{version}.tar.gz",
]
DZ_SIGNING_PUBKEY = "DE28AB241FA48260FAC9B8BAA7C9B38522604281"
def setup_logging():
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def sign_asset(asset, detached=True):
"""Sign a single Dangerzone asset using GPG.
By default, ask GPG to create a detached signature. Alternatively, ask it to include
the signature with the contents of the file.
"""
_sign_opt = "--detach-sig" if detached else "--clearsign"
cmd = [
"gpg",
"--batch",
"--yes",
"--armor",
_sign_opt,
"-u",
DZ_SIGNING_PUBKEY,
str(asset),
]
log.info(f"Signing '{asset}'")
log.debug(f"GPG command: {' '.join(cmd)}")
subprocess.run(cmd, check=True)
def hash_assets(assets):
"""Create a list of hashes for all the assets, mimicking the output of `sha256sum`.
Compute the SHA-256 hash of every asset, and create a line for each asset that
follows the format of `sha256sum`. From `man sha256sum`:
The sums are computed as described in FIPS-180-2. When checking, the input
should be a former output of this program. The default mode is to print a
line with: checksum, a space, a character indicating input mode ('*' for
binary, ' ' for text or where binary is insignificant), and name for each
FILE.
"""
checksums = []
for asset in assets:
log.info(f"Hashing '{asset}'")
with open(asset, "rb") as f:
hexdigest = hashlib.file_digest(f, "sha256").hexdigest()
checksums.append(f"{hexdigest} {asset.name}")
return "\n".join(checksums)
def ensure_assets_exist(assets):
"""Ensure that assets dir exists, and that the assets are all there."""
dir = assets[0].parent
if not dir.exists():
raise ValueError(f"Path '{dir}' does not exist")
if not dir.is_dir():
raise ValueError(f"Path '{dir}' is not a directory")
for asset in assets:
if not asset.exists():
raise ValueError(
f"Expected asset with name '{asset}', but it does not exist"
)
def main():
parser = argparse.ArgumentParser(
prog=sys.argv[0],
description="Dev script for signing Dangerzone assets",
)
parser.add_argument(
"--version",
required=True,
help="look for assets with this Dangerzone version",
)
parser.add_argument(
"dir",
help="look for assets in this directory",
)
args = parser.parse_args()
setup_logging()
# Ensure that all the necessary assets exist in the provided directory.
log.info("> Ensuring that the required assets exist")
dir = pathlib.Path(args.dir)
assets = [dir / asset.format(version=args.version) for asset in DZ_ASSETS]
ensure_assets_exist(assets)
# Create a file that holds the SHA-256 hashes of the assets.
log.info("> Create a checksums file for our assets")
checksums = hash_assets(assets)
checksums_file = dir / f"checksums-{args.version}.txt"
with open(checksums_file, "w+") as f:
f.write(checksums)
# Sign every asset and create a detached signature (.asc) for each one of them. The
# sole exception is the checksums file, which embeds its signature within the
# file, and retains its original name.
log.info("> Sign all of our assets")
for asset in assets:
sign_asset(asset)
sign_asset(checksums_file, detached=False)
(dir / f"checksums-{args.version}.txt.asc").rename(checksums_file)
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,2 +0,0 @@
[storage]
driver = "overlay"

View file

@ -1,133 +0,0 @@
#!/usr/bin/env python3
import argparse
import getpass
import logging
import os
import sys
import requests
log = logging.getLogger(__name__)
DEFAULT_HEADERS = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def get_auth_header(token):
return {"Authorization": f"Bearer {token}"}
def get_latest_draft_release(token):
url = "https://api.github.com/repos/freedomofpress/dangerzone/releases"
headers = DEFAULT_HEADERS.copy()
headers.update(get_auth_header(token))
r = requests.get(url, headers=headers)
r.raise_for_status()
draft_releases = [release["id"] for release in r.json() if release["draft"]]
if len(draft_releases) > 1:
raise RuntimeError("Found more than one draft releases")
elif len(draft_releases) == 0:
raise RuntimeError("No draft releases have been found")
return draft_releases[0]
def get_release_from_tag(token, tag):
url = f"https://api.github.com/repos/freedomofpress/dangerzone/releases/tags/v{tag}"
headers = DEFAULT_HEADERS.copy()
headers.update(get_auth_header(token))
r = requests.get(url, headers=headers)
r.raise_for_status()
return r.json()["id"]
def upload_asset(token, release_id, path):
filename = os.path.basename(path)
url = f"https://uploads.github.com/repos/freedomofpress/dangerzone/releases/{release_id}/assets?name={filename}"
headers = DEFAULT_HEADERS.copy()
headers.update(get_auth_header(token))
headers["Content-Type"] = "application/octet-stream"
with open(path, "rb") as f:
data = f.read()
# XXX: We have to load the data in-memory. Another solution is to use multipart
# encoding, but this doesn't work for GitHub.
r = requests.post(url, headers=headers, data=data)
r.raise_for_status()
def setup_logging():
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
def main():
parser = argparse.ArgumentParser(
prog=sys.argv[0],
description="Dev script for uploading assets to a GitHub release",
)
parser.add_argument(
"--token",
help="the file path to the GitHub token we will use for uploading assets",
)
parser.add_argument(
"--tag",
help="use the release with this tag",
)
parser.add_argument(
"--release-id",
help="use the release with this ID",
)
parser.add_argument(
"--draft",
action="store_true",
help="use the latest draft release",
)
parser.add_argument(
"file",
help="the file path to the asset we want to upload",
)
args = parser.parse_args()
setup_logging()
if args.token:
log.debug(f"Reading token from {args.token}")
# Ensure we are not uploading the token as an asset
assert args.file != args.token
with open(args.token) as f:
token = f.read().strip()
else:
token = getpass.getpass("Token: ")
if args.tag:
log.debug(f"Getting the ID of the {args.tag} release")
release_id = get_release_from_tag(token, args.tag)
log.debug(f"The {args.tag} release has ID '{release_id}'")
elif args.release_id:
release_id = args.release_id
else:
log.debug("Getting the ID of the latest draft release")
release_id = get_latest_draft_release(token)
log.debug(f"The latest draft release has ID '{release_id}'")
log.info(f"Uploading file '{args.file}' to GitHub release '{release_id}'")
upload_asset(token, release_id, args.file)
log.info(
f"Successfully uploaded file '{args.file}' to GitHub release '{release_id}'"
)
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,13 +0,0 @@
# Security Advisory 2023-10-25
For users testing our [new Qubes integration (beta)](https://github.com/freedomofpress/dangerzone/blob/main/INSTALL.md#qubes-os), please note that our instructions were missing a configuration detail for disposable VMs which is necessary to fully harden the configuration.
These instructions apply to users who followed the setup instructions **before October 25, 2023**.
**What you need to do:** run the following command in dom0:
```bash
qvm-prefs dz-dvm default_dispvm ''
```
**Explanation**: In Qubes OS, the default template for disposable VMs is network-connected. For this reason, we instruct users to create their own disposable VM (`dz-dvm`). However, adversaries with the ability to execute commands on `dz-dvm` would also be able open new disposable VMs with the default settings. By setting the default_dispvm to "none" we prevent this bypass.

View file

@ -1,32 +0,0 @@
Security Advisory 2023-12-07
In Dangerzone, a security vulnerability was detected in the quarantined
environment where documents are opened. Vulnerabilities like this are expected
and do not compromise the security of Dangerzone. However, in combination with
another more serious vulnerability (also called container escape), a malicious
document may be able to breach the security of Dangerzone. We are not aware of
any container escapes that affect Dangerzone. **To reduce that risk, you are
strongly advised to update Dangerzone to the latest version**.
# Summary
A security vulnerability in GhostScript (CVE-2023-43115) affects the
**contained** environment where the document rendering takes place. If one
attempts to convert a malicious file with an embedded PostScript image,
arbitrary code may run within that environment. Such files look like regular
Office documents, which means that you cannot avoid a specific extension. Other
programs that open Office documents, such as LibreOffice, are also affected,
unless the system has been upgraded in the meantime.
# How does this impact me?
The expectation is that malicious code will run in a container without Internet
access, meaning that it won't be able to infect the rest of the system.
# What do I need to do?
You are **strongly** advised to update your Dangerzone installation to 0.5.1 as
soon as possible.
Please note that we have recently enabled security scans for our software, and
we aim to alert people even sooner about vulnerabilities like these.

View file

@ -1,33 +0,0 @@
Security Advisory 2024-12-24
In Dangerzone, a security vulnerability was detected in the quarantined
environment where documents are opened. Vulnerabilities like this are expected
and do not compromise the security of Dangerzone. However, in combination with
another more serious vulnerability (also called container escape), a malicious
document may be able to breach the security of Dangerzone. We are not aware of
any container escapes that affect Dangerzone. **To reduce that risk, you are
strongly advised to update Dangerzone to the latest version**.
# Summary
A series of vulnerabilities in gst-plugins-base (CVE-2024-47538, CVE-2024-47607
and CVE-2024-47615) affects the **contained** environment where the document
rendering takes place.
If one attempts to convert a malicious file with an embedded Vorbis or Opus
media elements, arbitrary code may run within that environment. Such files
look like regular Office documents, which means that you cannot avoid a specific
extension. Other programs that open Office documents, such as LibreOffice, are
also affected, unless the system has been upgraded in the meantime.
# How does this impact me?
The expectation is that malicious code will run in a container without Internet
access, meaning that it won't be able to infect the rest of the system.
If you are running Dangerzone via the Qubes OS, you are not impacted.
# What do I need to do?
You are **strongly** advised to update your Dangerzone installation to 0.8.1 as
soon as possible.

View file

@ -1,38 +0,0 @@
# Dangerzone Testing
Dangerzone has some automated testing under `tests/`.
The following assumes that you have already setup the development environment.
## Run tests
Unit / integration tests are run with:
```bash
poetry run make test
```
## Run large tests
We also have a larger set of tests that can take a day or more to run, where we evaluate the completeness of Dangerzone conversions.
```bash
poetry run make test-large
```
### Test report generation
After running the large tests, a report is stored under `tests/test_docs_large/results/junit/` and it is composed of the JUnit XML file describing the pytest run.
This report can be analysed for errors. It is obtained by running:
```bash
cd tests/docs_test_large
make report
```
If you want to run the report on some historical test result, you can call:
```bash
cd tests/docs_test_large
python report.py tests/test_docs_large/results/junit/commit_<COMMIT_ID>.junit.xml
```

View file

@ -1,54 +0,0 @@
# Using the Doit Automation Tool
Developers can use the [Doit](https://pydoit.org/) automation tool to create
release artifacts. The purpose of the tool is to automate the manual release
instructions in `RELEASE.md` file. Not everything is automated yet, since we're
still experimenting with this tool. You can find our task definitions in this
repo's `dodo.py` file.
## Why Doit?
We picked Doit out of the various tools out there for the following reasons:
* **Pythonic:** The configuration file and tasks can be written in Python. Where
applicable, it's easy to issue shell commands as well.
* **File targets:** Doit borrows the file target concept from Makefiles. Tasks
can have file dependencies, and targets they build. This makes it easy to
define a dependency graph for tasks.
* **Hash-based caching:** Unlike Makefiles, doit does not look at the
modification timestamp of source/target files, to figure out if it needs to
run them. Instead, it hashes those files, and will run a task only if the
hash of a file dependency has changed.
* **Parallelization:** Tasks can be run in parallel with the `-n` argument,
which is similar to `make`'s `-j` argument.
## How to Doit?
First, enter your Poetry shell. Then, make sure that your environment is clean,
and you have ample disk space. You can run:
```bash
doit clean --dry-run # if you want to see what would happen
doit clean # you'll be asked to cofirm that you want to clean everything
```
Finally, you can build all the release artifacts with `doit`, or a specific task
with:
```
doit <task>
```
## Tips and tricks
* You can run `doit list --all -s` to see the full list of tasks, their
dependencies, and whether they are up to date (U) or will run (R). Note that
certain small tasks are always configured to run.
* You can run `doit info <task>` to see which dependencies are missing.
* You can pass the following environment variables to the script, in order to
affect some global parameters:
- `CONTAINER_RUNTIME`: The container runtime to use. Either `podman` (default)
or `docker`.
- `RELEASE_DIR`: Where to store the release artifacts. Default path is
`~/release-assets/<version>`
- `APPLE_ID`: The Apple ID to use when signing/notarizing the macOS DMG.

View file

@ -1,133 +0,0 @@
# Create Dangerzone environments
The `dev_scripts/env.py` script creates environments where a user can run
Dangerzone, allows the user to run arbitrary commands in these environments, as
well as run Dangerzone (nested containerization).
It supports two types of environments:
1. Dev environment. This environment has developer tools, necessary for
Dangerzone, baked in. Also, it mounts the Dangerzone source under
`/home/user/dangerzone` in the container. The developer can then run
Dangerzone from source, with `poetry run ./dev_scripts/dangerzone`.
2. End-user environment. This environment has only Dangerzone installed in it,
from the .deb/.rpm package that we have created. For convenience, it also has
the Dangerzone source mounted under `/home/user/dangerzone`, but it lacks
Poetry and other build tools. The developer can run Dangerzone there with
`dangerzone`. This environment is the most vanilla Dangerzone environment,
and should be closer to the end user's environment, than the development
environment.
Each environment corresponds to a Dockerfile, which is generated on the fly. The
developer can see this Dockerfile by passing `--show-dockerfile`.
For usage information, run `./dev_scripts/env.py --help`.
## Nested containerization
Since the Dangerzone environments are containers, this means that the Podman
containers that Dangerzone creates have to be nested containers. This has some
challenges that we will highlight below:
1. Containers typically only have a subset of syscalls allowed, and sometimes
only for specific arguments. This happens with the use of
[seccomp filters](https://docs.docker.com/engine/security/seccomp/). For
instance, in Docker, the `clone` syscall is limited in containers and cannot
create new namespaces
(https://docs.docker.com/engine/security/seccomp/#significant-syscalls-blocked-by-the-default-profile). For testing/development purposes, we can get around this limitation
by disabling the seccomp filters for the external container with
`--security-opt seccomp=unconfined`. This has the same effect as developing
Dangerzone locally, so it should probably be sufficient for now.
2. While Linux supports nested namespaces, we need extra handling for nested
user namespaces. By default, the configuration for each user namespace (see
[`man login.defs`](https://man7.org/linux/man-pages/man5/login.defs.5.html)
is to reserve 65536 UIDs/GIDs, starting from UID/GID 100000. This works fine
for the first container, but can't work for the nested container, since it
doesn't have enough UIDs/GIDs to refer to UID 100000. Our solution to this is
to restrict the number of UIDs/GIDs allowed in the nested container to 2000,
which should be enough to run `podman` in it.
3. Containers also restrict the capabilities (see
[`man capabilities`](https://man7.org/linux/man-pages/man7/capabilities.7.html))
of the processes that run in them. By default, containers do not have mount
capabilities, since it requires `CAP_SYS_ADMIN`, which effectively
[makes the process root](https://lwn.net/Articles/486306/) in the specific
user namespace. In our case, we have to give the Dangerzone environment this
capability, since it will have to mount directories in Podman containers. For
this reason, as well as some extra things we bumped into during development,
we pass `--privileged` when creating the Dangerzone environment, which
includes the `CAP_SYS_ADMIN` capability.
## GUI containerization
Running a GUI app in a container is a tricky subject for multi-platform apps. In
our case, we deal specifically with Linux environments, so we can target just
this platform.
To understand how a GUI app can draw in the user's screen from within a
container, we must first understand how it does so outside the container. In
Unix-like systems, GUI apps act like
[clients to a display server](https://wayland.freedesktop.org/architecture.html).
The most common display server implementation is X11, and the runner-up is
Wayland. Both of these display servers share some common traits, mainly that
they use Unix domain sockets as a way of letting clients communicate with them.
So, this gives us the answer on how one can run a containerized GUI app; they
can simply mount the Unix Domain Socket in the container. In practice this is
more nuanced, for two reasons:
1. Wayland support is not that mature on Linux, so we need to
[set some extra environment variables](https://github.com/mviereck/x11docker/wiki/How-to-provide-Wayland-socket-to-docker-container). To simplify things, we will target
X11 / XWayland hosts, which are the majority of the Linux OSes out there.
2. Sharing the Unix Domain socket does not allow the client to talk to the
display server, for security reasons. In order to allow the client, we need
to mount a magic cookie stored in a file pointed at by the `$XAUTHORITY`
envvar. Else, we can use `xhost`, which is considered slightly more dangerous
for multi-user environments.
## Caching and Reproducibility
In order to build Dangerzone environments, the script uses the following inputs:
* Dev environment:
- Distro name and version. Together, these comprise the base container image.
- `poetry.lock` and `pyproject.toml`. Together, these comprise the build
context.
* End-user environment:
- Distro name and version. Together, these comprise the base container image.
- `.deb` / `.rpm` Dangerzone package, as found under `deb_dist/` or `dist/`
respectively.
Any change in these inputs busts the cache for the corresponding image. In
theory, this means that the Dangerzone environment for each commit can be built
reproducibly. In practice, there are some issues that we haven't covered yet:
1. The output images are:
* Dev: `dangerzone.rocks/build/{distro_name}:{distro_version}`
* End-user: `dangerzone.rocks/{distro_name}:{distro_version}`
These images do not contain the commit/version of the Dangerzone source they
got created from, so each one overrides the other.
2. The end-user environment expects a `.deb.` / `.rpm` tagged with the version
of Dangerzone, but it doesn't insist being built from the current Dangerzone
commit. This means that stale packages may be installed in the end-user
environment.
3. The base images may be different in various environments, depending on when
they where pulled.
## State
The main goal behind these Dangerzone environments is to make them immutable,
so that they do not require to be stored somewhere, but can be recreated from
their images. Any change to these environments should therefore be reflected to
their Dockerfile.
To enforce immutability, we delete the containers every time we run a command or
an interactive shell exits. This means that these environments are suitable only
for running Dangerzone commands, and not doing actual development in them
(install an editor, configure bash prompts, etc.)
The only point where we allow mutability is the directory where Podman stores
the images and stopped containers, which may be useful for developers. If this
proves to be an issue, we will reconsider.

View file

@ -1,295 +0,0 @@
# gVisor integration
> [!NOTE]
> **Update on 2025-01-13:** There is no longer a copied container image under
> `/home/dangerzone/dangerzone-image/rootfs`. We now reuse the same container
> image both for the inner and outer container. See
> [#1048](https://github.com/freedomofpress/dangerzone/issues/1048).
Dangerzone has relied on the container runtime available in each supported
operating system (Docker Desktop on Windows / macOS, Podman on Linux) to isolate
the host from the sanitization process. The problem with this type of isolation
is that it exposes a rather large attack surface; the Linux kernel.
[gVisor](https://gvisor.dev/) is an application kernel, that emulates a
substantial portion of the Linux Kernel API in Go. What's more interesting to
Dangerzone is that it also offers an OCI runtime (`runsc`) that enables
containers to transparently run this application kernel.
As of writing this, Dangerzone uses two containers to sanitize a document:
* The first container reads a document from stdin, converts each page to pixels,
and writes them to stdout.
* The second container reads the pixels from a mounted volume (the host has
taken care of this), and saves the final PDF to another mounted volume.
Our threat model considers the computation and output of the first container
as **untrusted**, and the computation and output of the second container as
trusted. For this reason, and because we are about to remove the need for the
second container, our integration plan will focus on the first container.
## Design overview
Our integration goals are to:
* Make gVisor available to all of our supported platforms.
* Do not ask from users to run any commands on their system to do so.
Because gVisor does not support Windows and macOS systems out of the box,
Dangerzone will be responsible for "shipping" gVisor to those users. It will do
so using nested containers:
* The **outer** container is the Docker/Podman container that Dangerzone uses
already. This container acts as our **portability** layer. It's main purpose
is to bundle all the necessary configuration files and program to run gVisor
in all of our platforms.
* The **inner** container is the gVisor container, created with `runsc`. This
container acts as our **isolation layer**. It is responsible for running the
Python code that rasterizes a document, in a way that will be fully isolated
from the host.
### Building the container image
This nested container approach directly affects the container image as well,
which will also have two layers:
* The **outer** container image will contain just Python3 and `runsc`, the
latter downloaded from the official gVisor website. It will also contain an
entrypoint that will launch `runsc`. Finally, it will contain the **inner**
container image (see below) as filesystem clone under
`/dangerzone-image/rootfs`.
* The **inner** container image is practically the original Dangerzone image, as
we've always built it, which contains the necessary tooling to rasterize a
document.
### Spawning the container
Spawning the container now becomes a multi-stage process:
The `Container` isolation provider spawns the container as before, with the
following changes:
* It adds the `SYS_CHROOT` Linux capability, which was previously dropped, to
the **outer** container. This capability is necessary to run `runsc`
rootless, and is not inherited by the **inner** container.
* It removes the `--userns keep-id` argument, which mapped the user outside the
container to the same UID (normally `1000`) within the container. This was
originally required when we were mounting host directories within the
container, but this no longer applies to the gVisor integration. By removing
this flag, the host user maps to the root user within the container (UID `0`).
- In distributions that offer Podman version 4 or greater, we use the
`--userns nomap` flag. This flag greatly minimizes the attack surface,
since the host user is not mapped within the container at all.
* We use our custom seccomp policy across container engines, since some do not
allow the `ptrace` syscall (see
[#846](https://github.com/freedomofpress/dangerzone/issues/846)).
* It labels the **outer** container with the `container_engine_t` SELinux label.
This label is reserved for running a container engine within a container, and
is necessary in environments where SELinux is enabled in enforcing mode (see
[#880](https://github.com/freedomofpress/dangerzone/issues/880)).
Then, the following happens when Podman/Docker spawns the container:
1. _(outer container)_ The entrypoint code finds from `sys.argv` the command
that Dangerzone passed to the `docker run` / `podman run` invocation.
Typically, this command is:
```
/usr/bin/python3 -m dangerzone.conversion.doc_to_pixels
```
2. _(outer container)_ The entrypoint code then creates an OCI config for
`runsc` with the following properties:
* Use UID/GID 1000 in the **inner** container image.
* Run the command we detected on step 1.
* Drop all Linux capabilities.
* Limit the number of open files to 4096.
* Use the `/dangerzone-image/rootfs` directory as the root path for the
**inner** container.
* Mount a gVisor view of the `procfs` hierarchy under `/proc` , and then
mount `tmpfs` in the `/dev`, `/sys` and `/tmp` mount points. This way, no
host-specific info may leak to the **inner** container.
- Mount `tmpfs` on some more mountpoints where we want write access.
3. _(outer container)_ If `RUNSC_DEBUG` has been specified, add some debug
arguments to `runsc` (applies to development environments only).
4. _(outer container)_ If `RUNSC_FLAGS` has been specified, pass some
user-specified flags to `runsc` (applies to development environments only).
5. _(outer container)_ Spawn `runsc` as a Python subprocess, and wait for it to
complete.
6. _(inner container)_ Read the document from stdin and write pixels to stdout.
- In practice, nothing changes here, as far as the document conversion is
concerned. The Python process transparently uses the emulated Linux Kernel
API that gVisor provides.
7. _(outer container)_ Exit the container with the same exit code as the inner
container.
## Implementation details
### Creating the outer container image
In order to achieve the above, we add one more build stage in our Dockerfile
(see [multi-stage builds](https://docs.docker.com/build/building/multi-stage/))
that copies the result of the previous stages under `/dangerzone-image/rootfs`.
Also, we install `runsc` and Python, and copy our entrypoint to that layer.
Here's how it looks like:
```dockerfile
# NOTE: The following lines are appended to the end of our original Dockerfile.
# Install some commands required by the entrypoint.
FROM alpine:latest
RUN apk --no-cache -U upgrade && \
apk --no-cache add \
python3 \
su-exec
# Add the previous build stage (`dangerzone-image`) as a filesystem clone under
# the /dangerzone-image/rootfs directory.
RUN mkdir --mode=0755 -p /dangerzone-image/rootfs
COPY --from=dangerzone-image / /dangerzone-image/rootfs
# Download and install gVisor, based on the official instructions.
RUN GVISOR_URL="https://storage.googleapis.com/gvisor/releases/release/latest/$(uname -m)"; \
wget "${GVISOR_URL}/runsc" "${GVISOR_URL}/runsc.sha512" && \
sha512sum -c runsc.sha512 && \
rm -f runsc.sha512 && \
chmod 555 runsc /entrypoint.py && \
mv runsc /usr/bin/
COPY gvisor_wrapper/entrypoint.py /
ENTRYPOINT ["/entrypoint.py"]
```
### OCI config
The OCI config that gets produced is similar to this:
```json
{
"ociVersion": "1.0.0",
"process": {
"user": {
"uid": 1000,
"gid": 1000
},
"args": [
"/usr/bin/python3",
"-m",
"dangerzone.conversion.doc_to_pixels"
],
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"PYTHONPATH=/opt/dangerzone",
"TERM=xterm"
],
"cwd": "/",
"capabilities": {
"bounding": [],
"effective": [],
"inheritable": [],
"permitted": [],
},
"rlimits": [
{
"type": "RLIMIT_NOFILE",
"hard": 4096,
"soft": 4096
}
]
},
"root": {
"path": "rootfs",
"readonly": true
},
"hostname": "dangerzone",
"mounts": [
{
"destination": "/proc",
"type": "proc",
"source": "proc"
},
{
"destination": "/dev",
"type": "tmpfs",
"source": "tmpfs",
"options": [
"nosuid",
"noexec",
"nodev"
]
},
{
"destination": "/sys",
"type": "tmpfs",
"source": "tmpfs",
"options": [
"nosuid",
"noexec",
"nodev",
"ro"
]
},
{
"destination": "/tmp",
"type": "tmpfs",
"source": "tmpfs",
"options": [
"nosuid",
"noexec",
"nodev"
]
},
{
"destination": "/home/dangerzone",
"type": "tmpfs",
"source": "tmpfs",
"options": [
"nosuid",
"noexec",
"nodev"
]
},
{
"destination": "/usr/lib/libreoffice/share/extensions/",
"type": "tmpfs",
"source": "tmpfs",
"options": [
"nosuid",
"noexec",
"nodev"
]
}
],
"linux": {
"namespaces": [
{
"type": "pid"
},
{
"type": "network"
},
{
"type": "ipc"
},
{
"type": "uts"
},
{
"type": "mount"
}
]
}
}
```
## Security considerations
* gVisor does not have an official release on Alpine Linux. The developers
provide gVisor binaries from a GCS bucket. In order to verify the integrity of
these binaries, they also provide a SHA-512 hash of the files.
- If we choose to pin the hash, then we essentially pin gVisor, and we may
lose security updates.
## Alternatives
gVisor can be integrated with Podman/Docker, but this is the case only on Linux.
Because we want gVisor on Windows and macOS as well, we decided to not move
forward with this approach.

View file

@ -1,14 +0,0 @@
# Scripted QA
The `dev_scripts/qa.py` script runs the QA steps for a supported platform, in
order to make sure that the dev does not skip something. These steps are taken
from our [release instructions](../../RELEASE.md#qa).
The idea behind this script is that it will present each step to the user and
ask them to perform it manually and specify it passes, in order to continue to
the next one. For specific steps, it allows the user to run them automatically.
In steps that require a Dangerzone dev environment, this script uses the
`env.py` script to create one.
Including all the supported platforms in this script is still a work in
progress.

View file

@ -1,67 +0,0 @@
# Reproducible builds
We want to improve the transparency and auditability of our build artifacts, and
a way to achieve this is via reproducible builds. For a broader understanding of
what reproducible builds entail, check out https://reproducible-builds.org/.
Our build artifacts consist of:
* Container images (`amd64` and `arm64` architectures)
* macOS installers (for Intel and Apple Silicon CPUs)
* Windows installer
* Fedora packages (for regular Fedora distros and Qubes)
* Debian packages (for Debian and Ubuntu)
As of writing this, only the following artifacts are reproducible:
* Container images (see [#1047](https://github.com/freedomofpress/dangerzone/issues/1047))
In the following sections, we'll mention some specifics about enforcing
reproducibility for each artifact type.
## Container image
### Updating the image
The fact that our image is reproducible also means that it's frozen in time.
This means that rebuilding the image without updating our Dockerfile will
**not** receive security updates.
Here are the necessary variables that make up our image in the `Dockerfile.env`
file:
* `DEBIAN_IMAGE_DIGEST`: The index digest for the Debian container image
* `DEBIAN_ARCHIVE_DATE`: The Debian snapshot repo that we want to use
* `GVISOR_ARCHIVE_DATE`: The gVisor APT repo that we want to use
* `H2ORESTART_CHECKSUM`: The SHA-256 checksum of the H2ORestart plugin
* `H2ORESTART_VERSION`: The version of the H2ORestart plugin
If you update these values in `Dockerfile.env`, you must also create a new
Dockerfile with:
```
make Dockerfile
```
Updating `Dockerfile` without bumping `Dockerfile.in` is detected and should
trigger a CI error.
### Reproducing the image
For a simple way to reproduce a Dangerzone container image, you can checkout the
commit this image was built from (you can find it from the image tag in its
`g<commit>` portion), retrieve the date it was built (also included in the image
tag), and run the following command in any environment:
```
./dev_scripts/reproduce-image.py \
--debian-archive-date <date> \
<digest>
```
where:
* `<date>` should be given in YYYYMMDD format, e.g, 20250226
* `<digest>` is the SHA-256 hash of the image for the **current platform**, with
or without the `sha256:` prefix.
This command will build a container image from the current Git commit and the
provided date for the Debian archives. Then, it will compare the digest of the
manifest against the provided one. This is a simple way to ensure that the
created image is bit-for-bit reproducible.

View file

@ -1,222 +0,0 @@
# Update notifications
This design document explains how the notification mechanism for Dangerzone
updates works, what are its benefits and limitations, and what other
alternatives we have considered. It has been adapted by discussions on GitHub
issue [#189](https://github.com/freedomofpress/dangerzone/issues/189), and has
been updated to reflect the current design.
A user-facing document on how update notifications work can be found in
https://github.com/freedomofpress/dangerzone/wiki/Updates
## Design overview
This feature introduces a hamburger icon that will be visible across almost all
of the Dangerzone windows. This will be used to notify the users about updates.
### First run
_We detect it's the first time Dangerzone runs because the
`settings["updater_last_check"] is None`._
Add the following keys in our `settings.json` file.
* `"updater_check": None`: Whether to check for updates or not. `None` means
that the user has not decided yet, and is the default.
* `"updater_last_check": None`: The last time we checked for updates (in seconds
from Unix epoch). None means that we haven't checked yet.
* `"updater_latest_version": "0.4.2"`: The latest version that the Dangerzone
updater has detected. By default it's the current version.
* `"updater_latest_changelog": ""`: The latest changelog that the Dangerzone
updater has detected. By default it's empty.
* `"updater_errors: 0`: The number of update check errors that we have
encountered in a row.
Note:
* If on Linux, make `"updater_check": False`, since we normally have
other update channels for these platforms.
### Second run
_We detect it's the second time Dangerzone runs because
`settings["updater_check"] is not None and settings["updater_last_check"] is
None`._
Before starting up the main window, show this window:
* Title: Dangerzone Updater
* Body:
> Do you want Dangerzone to automatically check for updates?
>
> If you accept, Dangerzone will check the latest releases page in github.com
> on startup. Otherwise it will make no network requests and won't inform you
> about new releases.
>
> If you prefer another way of getting notified about new releases, we suggest adding
> to your RSS reader our [Mastodon feed](https://fosstodon.org/@dangerzone.rss). For more information
> about updates, check [this webpage](https://github.com/freedomofpress/dangerzone/wiki/Updates).
* Buttons:
- Check Automaticaly: Store `settings["updater_check"] = True`
- Don't Check: Store `settings["updater_check"] = False`
Note:
* Users will be able to change their choice from the hamburger menu, which will
contain an entry called "Check for updates", that users can check and uncheck.
### Subsequent runs
_We perform the following only if `settings["updater_check"] == True`._
1. Spawn a new thread so that we don't block the main window.
2. Check if we have cached information about a release (version and changelog).
If yes, return those immediately.
3. Check if the last time we checked for new releases was less than 12 hours
ago. In that case, skip this update check so that we don't leak telemetry
stats to GitHub.
4. Hit the GitHub releases API and get the [latest release](https://api.github.com/repos/freedomofpress/dangerzone/releases/latest).
Store the current time as the last check time, even if the call fails.
5. Check if the latest release matches `settings["updater_latest_version"]`. If
yes, return an empty update report.
6. If a new update has been detected, return the version number and the
changelog.
7. Add a green bubble in the notification icon, and a menu entry called "New
version available".
8. Users who click on this entry will see a dialog with more info:
* Title: "Dangerzone v0.5.0 has been released"
* Body:
> A new Dangerzone version been released. Please visit our [downloads page](https://dangerzone.rocks#downloads) to install this update.
>
> (Show changelog rendered from Markdown in a collapsible text box)
* Buttons:
- OK: Return
Notes:
* Any successful attempt to fetch info from GitHub will result in clearing the
`settings["updater_errors"]` key.
### Error handling
_We trigger error handling when the updater thread encounters an error (either
due to an HTTPS failure or a Python exception) and does not complete
successfully._
1. Bump the number of errors we've encountered in a row
(`settings["updater_errors"] += 1`)
2. Return an update report with the error we've encountered.
3. Update the hamburger menu with a red notification bubble, and add a menu
entry called "Update error".
4. If a user clicks on this menu entry, show a dialog window:
* Title: "Update check error"
* Body:
> Something went wrong while checking for Dangerzone updates:
>
> You are strongly advised to visit our [downloads page](https://dangerzone.rocks#downloads) and check for new updates manually, or consult [this page](https://github.com/freedomofpress/dangerzone/wiki/Updates) for common causes of errors . Alternatively, you can uncheck "Check for updates", if you are in an air-gapped environment and have another way of learning about updates.
>
> (Show the latest error message in a scrollable, copyable text box)
* Buttons:
- Close: Return
## Key Benefits
1. The above approach future-proofs Dangerzone against API changes or bugs in
the update check process, by asking users to manually visit
https://dangerzone.rocks.
2. If we want to draw the attention of users to immediately install a release,
we can do so in the release body, which we will show in a pop-up window.
3. If we are aware of issues that prevent updates, we can add them in the wiki
page that we show in the error popup. Wiki pages are not versioned, so we can
add useful info even after a release.
## Security Considerations
Because this approach does not download binaries / auto-updates, it **does not
add any more security issues** than the existing, manual way of installing
updates. These issues have to do with a compromised/malicous GitHub service, and
are the following:
1. GitHub pages can alter the contents of our main site
(https://dangerzone.rocks)
2. GitHub releases can serve an older, vulnerable version of Dangerzone, instead
of a new update.
3. GitHub releases can serve a malicious binary (requires a joint operation from
a malicious CA as well, for extra legitimacy).
4. GitHub releases can silently drop updates.
5. GitHub releases can know which users download Dangerzone updates.
6. Network attackers can know that a user has Dangerzone installed (because we ask the user to visit https://dangerzone.rocks)
A good update framework would probably defend against 1,2,3. This is not to say
that our users are currently unprotected, since 1-4 can be detected by the
general public and the developers (unless GitHub specifically targets an
individual, but that's another story).
## Usability Considerations
1. We do not have an update story for users that only use the Dangerzone CLI. A
good assumption is that they are on Linux, so they auto-update.
## Alternatives
We researched a bit on this subject and found out that there are update
frameworks that do this job for us. While working on this issue, we decided that
integrating with one framework will certainly take a bit of work, especially
given that we target both Windows and MacOS systems. In the meantime though, we
didn't want to have releases out without including at least a notification
channel, since staying behind on updates has a huge negative impact on the
users' safety.
The update frameworks that we learned about are:
## Sparkle Project
[Sparkle project](https://sparkle-project.org) seems to be the de-facto update
framework in MacOS. Integrators in practice need to care about two things:
creating a proper `Appcast.xml` file on the server-side, and calling the Sparkle
code from the client-side. These are covered in the project's
[documentation](https://sparkle-project.org/documentation/).
The client-side part is not very straight-forward, since Sparkle is written in
Objective-C. Thankfully, there are others who have ventured into this before:
https://fman.io/blog/codesigning-and-automatic-updates-for-pyqt-apps/
The server-side part is also not very straight-forward. For integrators that use
GitHub releases (like us), this issue may be of help:
https://github.com/sparkle-project/Sparkle/issues/648
The Windows platform is not covered by Sparkle itself, but there are other
projects, such as [WinSparkle](https://winsparkle.org/), that follow a similar
approach. I see that there's a [Python library (`pywinsparkle`)](https://pypi.org/project/pywinsparkle/)
for interacting with WinSparkle, so this may alleviate some pains.
Note that the Sparkle project is not a silver bullet. Development missteps can
happen, and users can be left without updates. Here's an [example issue](https://github.com/sparkle-project/Sparkle/issues/345) that showcases this.
## The Update Framework
[The Update Framework](https://theupdateframework.io/) is a graduated CNCF
project hosted by Linux Foundation. It's based on the
[Thandy](https://chromium.googlesource.com/chromium/src.git/+/master/docs/updater/protocol_3_1.md)
updater for Tor. It's [not widely adopted](https://github.com/sparkle-project/Sparkle/issues/345), but some of its
adopters are high-profile, and it has passed security audits.
It's more of a [specification](https://github.com/sparkle-project/Sparkle/issues/345)
and less of a software project, although a well-maintained
[reference implementation](https://github.com/sparkle-project/Sparkle/issues/345)
in Python exists. Also, a [Python project (`tufup`)](https://doc.qt.io/qtinstallerframework/ifw-updates.html)
that builds upon this implementation makes it even easier to generate the
required keys and files.
Regardless of whether we use it, knowing about the [threat vectors](https://theupdateframework.io/security/) that it's protecting against is very important.
## Other Projects
* Qt has some updater framework as well: https://doc.qt.io/qtinstallerframework/ifw-updates.html
* Google Chrome has it's own updater framework: https://chromium.googlesource.com/chromium/src.git/+/master/docs/updater/protocol_3_1.md
* Keepass rolls out its own way to update: https://github.com/keepassxreboot/keepassxc/blob/develop/src/updatecheck/UpdateChecker.cpp
* [PyUpdater](https://github.com/Digital-Sapphire/PyUpdater) was another popular updater project for Python, but is now archived.

View file

@ -1,53 +0,0 @@
# Podman Desktop support
Starting with Dangerzone 0.9.0, it is possible to use Podman Desktop on
Windows and macOS. The support for this container runtime is currently only
experimental. If you try it out and encounter issues, please reach to us, we'll
be glad to help.
With [Podman Desktop](https://podman-desktop.io/) installed on your machine,
here are the required steps to change the dangerzone container runtime.
You will be required to open a terminal and follow these steps:
## On macOS
You will need to configure podman to access the shared Dangerzone resources:
```bash
podman machine stop
podman machine rm
cat > ~/.config/containers/containers.conf <<EOF
[machine]
volumes = ["/Users:/Users", "/private:/private", "/var/folders:/var/folders", "/Applications/Dangerzone.app:/Applications/Dangerzone.app"]
EOF
podman machine init
podman machine set --rootful=false
podman machine start
```
Then, set the container runtime to podman using this command:
```bash
/Applications/Dangerzone.app/Contents/MacOS/dangerzone-cli --set-container-runtime podman
```
In order to get back to the default behaviour (Docker Desktop on macOS), pass
the `default` value instead:
```bash
/Applications/Dangerzone.app/Contents/MacOS/dangerzone-cli --set-container-runtime default
```
## On Windows
To set the container runtime to podman, use this command:
```bash
'C:\Program Files\Dangerzone\dangerzone-cli.exe' --set-container-runtime podman
```
To revert back to the default behavior, pass the `default` value:
```bash
'C:\Program Files\Dangerzone\dangerzone-cli.exe' --set-container-runtime podman
```

View file

@ -1,11 +0,0 @@
This release includes various new features, stability improvements, and security fixes **(adjust accordingly)**. If you are on a Mac or PC please also update Docker Desktop to the latest version to get the latest security fixes.
The highlights for this release are:
- **Important accomplishment**
We used to do [this](https://github.com/freedomofpress/dangerzone/issues/1), but now we do [that](https://github.com/freedomofpress/dangerzone/issues/2).
- **Support for a new platform**
We added support for a new platform ([#3](https://github.com/freedomofpress/dangerzone/issues/3))
- **Community contributions**
<!-- Acknowledge all contributions and talk about highlights ->
For a full list of the changes, see our [changelog](https://github.com/freedomofpress/dangerzone/blob/<RELEASE_TAG>/CHANGELOG.md#<RELEASE_ANCHOR>).

Some files were not shown because too many files have changed in this diff Show more