Compare commits

...

14 commits

Author SHA1 Message Date
Alexis Métaireau
0a7b79f61a
Add a set-container-runtime option to dangerzone-cli
This sets the container runtime in the settings, and provides an easy
way to do so for users, without having to mess with the json settings.

When setting the container runtime, one can just pass "podman" and the
path to the executable will be stored in the settings.
2025-03-31 16:20:29 +02:00
Alexis Métaireau
86eab5d222
Ensure that only podman and docker container runtimes can be used 2025-03-31 16:20:29 +02:00
Alexis Métaireau
ed39c056bb
Reset terminal colors after printing the banner 2025-03-31 16:20:29 +02:00
Alexis Métaireau
983622fe59
Update CHANGELOG 2025-03-31 16:20:29 +02:00
Alexis Métaireau
8e99764952
Use a Runtime class to get information about container runtimes
This is useful to avoid parsing too many times the settings.
2025-03-31 16:20:28 +02:00
Alexis Métaireau
20cd9cfc5c
Allow to define a container_runtime_path 2025-03-31 16:20:28 +02:00
Alexis Métaireau
f082641b71
Only check Docker version if the container runtime is set to docker 2025-03-31 16:20:28 +02:00
Alexis Métaireau
c0215062bc
Allow to read the container runtime from the settings
Add a few tests for this along the way, and update the end-user messages
about Docker/Podman to account for this change.
2025-03-31 16:20:28 +02:00
Alexis Métaireau
b551a4dec4
Mock the settings rather than monkeypatching external modules 2025-03-31 16:20:28 +02:00
Alexis Métaireau
5a56a7f055
Decouple the Settings class from DangerzoneCore
No real reason to pass the whole object where what we really need is
just the location of the configuration folder.
2025-03-31 16:20:28 +02:00
Alexis Métaireau
ab6dd9c01d
Use pathlib.Path to return path locations 2025-03-31 16:20:28 +02:00
Alex Pyrgiotis
dfcb74b427
Improve our release instructions regarding versioned links
Some checks failed
Tests / windows (push) Has been cancelled
Tests / macOS (arch64) (push) Has been cancelled
Tests / build-deb (ubuntu 24.04) (push) Has been cancelled
Tests / macOS (x86_64) (push) Has been cancelled
Tests / build-deb (debian bookworm) (push) Has been cancelled
Tests / build-deb (debian bullseye) (push) Has been cancelled
Tests / build-deb (debian trixie) (push) Has been cancelled
Tests / build-deb (ubuntu 22.04) (push) Has been cancelled
Tests / run tests (fedora 42) (push) Has been cancelled
Tests / build-deb (ubuntu 24.10) (push) Has been cancelled
Tests / build-deb (ubuntu 25.04) (push) Has been cancelled
Tests / install-deb (debian bookworm) (push) Has been cancelled
Tests / install-deb (debian bullseye) (push) Has been cancelled
Tests / run tests (ubuntu 22.04) (push) Has been cancelled
Tests / run tests (ubuntu 24.04) (push) Has been cancelled
Tests / run tests (ubuntu 24.10) (push) Has been cancelled
Tests / run tests (ubuntu 25.04) (push) Has been cancelled
Tests / install-deb (debian trixie) (push) Has been cancelled
Tests / install-deb (ubuntu 22.04) (push) Has been cancelled
Tests / install-deb (ubuntu 24.04) (push) Has been cancelled
Tests / install-deb (ubuntu 24.10) (push) Has been cancelled
Tests / install-deb (ubuntu 25.04) (push) Has been cancelled
Tests / build-install-rpm (fedora 40) (push) Has been cancelled
Tests / build-install-rpm (fedora 41) (push) Has been cancelled
Tests / build-install-rpm (fedora 42) (push) Has been cancelled
Tests / run tests (debian bookworm) (push) Has been cancelled
Tests / run tests (debian bullseye) (push) Has been cancelled
Tests / run tests (debian trixie) (push) Has been cancelled
Tests / run tests (fedora 40) (push) Has been cancelled
Tests / run tests (fedora 41) (push) Has been cancelled
Update our `RELEASE.md` so that we don't forget to bump the download
links in `INSTALL.md` prior to tagging a release. This way, we won't
have a versioned `INSTALL.md` page pointing to an older download link.

Note that this means that the latest version of the `INSTALL.md` page
will point to a broken link, in the short period of time between the
pre-release and the actual release. That's not an issue in our case,
because we don't point to the latest version of our `INSTALL.md` from
our `README.md`. We use versioned links instead, and thus we minimize
the chance that a user may encounter a broken link.

Fixes #1100
2025-03-28 15:04:05 +02:00
Alexis Métaireau
a910ccc273
Provide a way to opt-out from CHANGELOG check
Co-authored-by: Alex Pyrgiotis <alex.p@freedom.press>
2025-03-28 13:53:05 +01:00
dependabot[bot]
d868699bab
build(deps): bump slsa-framework/slsa-github-generator
Some checks failed
Tests / windows (push) Has been cancelled
Tests / macOS (arch64) (push) Has been cancelled
Tests / macOS (x86_64) (push) Has been cancelled
Tests / build-deb (debian bookworm) (push) Has been cancelled
Tests / build-deb (debian bullseye) (push) Has been cancelled
Tests / build-deb (debian trixie) (push) Has been cancelled
Tests / build-deb (ubuntu 22.04) (push) Has been cancelled
Tests / build-deb (ubuntu 24.04) (push) Has been cancelled
Tests / build-deb (ubuntu 24.10) (push) Has been cancelled
Tests / build-deb (ubuntu 25.04) (push) Has been cancelled
Tests / install-deb (debian bookworm) (push) Has been cancelled
Tests / install-deb (debian bullseye) (push) Has been cancelled
Tests / install-deb (debian trixie) (push) Has been cancelled
Tests / install-deb (ubuntu 22.04) (push) Has been cancelled
Tests / install-deb (ubuntu 24.04) (push) Has been cancelled
Tests / install-deb (ubuntu 24.10) (push) Has been cancelled
Tests / install-deb (ubuntu 25.04) (push) Has been cancelled
Tests / build-install-rpm (fedora 40) (push) Has been cancelled
Tests / build-install-rpm (fedora 41) (push) Has been cancelled
Tests / build-install-rpm (fedora 42) (push) Has been cancelled
Tests / run tests (debian bookworm) (push) Has been cancelled
Tests / run tests (debian bullseye) (push) Has been cancelled
Tests / run tests (debian trixie) (push) Has been cancelled
Tests / run tests (fedora 40) (push) Has been cancelled
Tests / run tests (fedora 41) (push) Has been cancelled
Tests / run tests (fedora 42) (push) Has been cancelled
Tests / run tests (ubuntu 22.04) (push) Has been cancelled
Tests / run tests (ubuntu 24.04) (push) Has been cancelled
Tests / run tests (ubuntu 24.10) (push) Has been cancelled
Tests / run tests (ubuntu 25.04) (push) Has been cancelled
Bumps [slsa-framework/slsa-github-generator](https://github.com/slsa-framework/slsa-github-generator) from 2.0.0 to 2.1.0.
- [Release notes](https://github.com/slsa-framework/slsa-github-generator/releases)
- [Changelog](https://github.com/slsa-framework/slsa-github-generator/blob/main/CHANGELOG.md)
- [Commits](https://github.com/slsa-framework/slsa-github-generator/compare/v2.0.0...v2.1.0)

---
updated-dependencies:
- dependency-name: slsa-framework/slsa-github-generator
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-26 14:54:50 +01:00
21 changed files with 328 additions and 196 deletions

View file

@ -209,7 +209,7 @@ jobs:
actions: read # for detecting the Github Actions environment. actions: read # for detecting the Github Actions environment.
id-token: write # for creating OIDC tokens for signing. id-token: write # for creating OIDC tokens for signing.
packages: write # for uploading attestations. packages: write # for uploading attestations.
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0 uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
with: with:
digest: ${{ needs.merge.outputs[format('digest_{0}', matrix.manifest_type)] }} digest: ${{ needs.merge.outputs[format('digest_{0}', matrix.manifest_type)] }}
image: ${{ needs.merge.outputs.image }} image: ${{ needs.merge.outputs.image }}

View file

@ -1,6 +1,7 @@
name: Check branch conformity name: Check branch conformity
on: on:
pull_request: pull_request:
types: ["opened", "labeled", "unlabeled", "reopened", "synchronize"]
jobs: jobs:
prevent-fixup-commits: prevent-fixup-commits:
@ -20,17 +21,10 @@ jobs:
check-changelog: check-changelog:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Ensure CHANGELOG.md is populated for user-visible changes
steps: steps:
- name: Checkout code # Pin the GitHub action to a specific commit that we have audited and know
uses: actions/checkout@v4 # how it works.
- uses: tarides/changelog-check-action@509965da3b8ac786a5e2da30c2ccf9661189121f
with: with:
fetch-depth: 0 changelog: CHANGELOG.md
- name: ensure CHANGELOG.md is populated
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
shell: bash
run: |
if git diff --exit-code "origin/${BASE_REF}" -- CHANGELOG.md; then
echo "::error::No CHANGELOG.md modifications were found in this pull request."
return -1;
fi

View file

@ -16,6 +16,15 @@ since 0.4.1, and this project adheres to [Semantic Versioning](https://semver.or
- Document Operating System support [#986](https://github.com/freedomofpress/dangerzone/issues/986) - 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) - Tests: Look for regressions when converting PDFs [#321](https://github.com/freedomofpress/dangerzone/issues/321)
## Added
- (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))
## [0.8.1](https://github.com/freedomofpress/dangerzone/compare/v0.8.1...0.8.0) ## [0.8.1](https://github.com/freedomofpress/dangerzone/compare/v0.8.1...0.8.0)
- Update the container image - Update the container image
@ -29,6 +38,10 @@ since 0.4.1, and this project adheres to [Semantic Versioning](https://semver.or
- Platform support: Drop support for Fedora 39, since it's end-of-life ([#999](https://github.com/freedomofpress/dangerzone/pull/999)) - 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 ### Development changes
Thanks [@jkarasti](https://github.com/jkarasti) for the contribution. Thanks [@jkarasti](https://github.com/jkarasti) for the contribution.

View file

@ -17,6 +17,7 @@ Here is a list of tasks that should be done before issuing the release:
- [ ] Bump the Debian version by adding a new changelog entry in `debian/changelog` - [ ] 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 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` - [ ] 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 - [ ] 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/) - [ ] 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/)
@ -340,7 +341,7 @@ To publish the release, you can follow these steps:
- [ ] Update the [Dangerzone website](https://github.com/freedomofpress/dangerzone.rocks) to link to the new installers. - [ ] 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 the brew cask release of Dangerzone with a [PR like this one](https://github.com/Homebrew/homebrew-cask/pull/116319)
- [ ] Update version and download links in `README.md` - [ ] Update version and links to our installation instructions (`INSTALL.md`) in `README.md`
## Post-release ## Post-release

View file

@ -11,6 +11,7 @@ from .isolation_provider.container import Container
from .isolation_provider.dummy import Dummy from .isolation_provider.dummy import Dummy
from .isolation_provider.qubes import Qubes, is_qubes_native_conversion from .isolation_provider.qubes import Qubes, is_qubes_native_conversion
from .logic import DangerzoneCore from .logic import DangerzoneCore
from .settings import Settings
from .util import get_version, replace_control_chars from .util import get_version, replace_control_chars
@ -37,7 +38,7 @@ def print_header(s: str) -> None:
) )
@click.argument( @click.argument(
"filenames", "filenames",
required=True, required=False,
nargs=-1, nargs=-1,
type=click.UNPROCESSED, type=click.UNPROCESSED,
callback=args.validate_input_filenames, callback=args.validate_input_filenames,
@ -48,17 +49,33 @@ def print_header(s: str) -> None:
flag_value=True, flag_value=True,
help="Run Dangerzone in debug mode, to get logs from gVisor.", help="Run Dangerzone in debug mode, to get logs from gVisor.",
) )
@click.option(
"--set-container-runtime",
required=False,
help="The path to the container runtime you want to set in the settings",
)
@click.version_option(version=get_version(), message="%(version)s") @click.version_option(version=get_version(), message="%(version)s")
@errors.handle_document_errors @errors.handle_document_errors
def cli_main( def cli_main(
output_filename: Optional[str], output_filename: Optional[str],
ocr_lang: Optional[str], ocr_lang: Optional[str],
filenames: List[str], filenames: Optional[List[str]],
archive: bool, archive: bool,
dummy_conversion: bool, dummy_conversion: bool,
debug: bool, debug: bool,
set_container_runtime: Optional[str] = None,
) -> None: ) -> None:
setup_logging() setup_logging()
display_banner()
if set_container_runtime:
settings = Settings()
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: if getattr(sys, "dangerzone_dev", False) and dummy_conversion:
dangerzone = DangerzoneCore(Dummy()) dangerzone = DangerzoneCore(Dummy())
@ -67,7 +84,6 @@ def cli_main(
else: else:
dangerzone = DangerzoneCore(Container(debug=debug)) dangerzone = DangerzoneCore(Container(debug=debug))
display_banner()
if len(filenames) == 1 and output_filename: if len(filenames) == 1 and output_filename:
dangerzone.add_document_from_filename(filenames[0], output_filename, archive) dangerzone.add_document_from_filename(filenames[0], output_filename, archive)
elif len(filenames) > 1 and output_filename: elif len(filenames) > 1 and output_filename:
@ -320,4 +336,10 @@ def display_banner() -> None:
+ Style.DIM + Style.DIM
+ "" + ""
) )
print(Back.BLACK + Fore.YELLOW + Style.DIM + "╰──────────────────────────╯") print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ "╰──────────────────────────╯"
+ Style.RESET_ALL
)

View file

@ -1,10 +1,13 @@
import logging import logging
import os
import platform import platform
import shutil import shutil
import subprocess import subprocess
from typing import List, Tuple from pathlib import Path
from typing import List, Optional, Tuple
from . import errors from . import errors
from .settings import Settings
from .util import get_resource_path, get_subprocess_startupinfo from .util import get_resource_path, get_subprocess_startupinfo
CONTAINER_NAME = "dangerzone.rocks/dangerzone" CONTAINER_NAME = "dangerzone.rocks/dangerzone"
@ -12,16 +15,47 @@ CONTAINER_NAME = "dangerzone.rocks/dangerzone"
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def get_runtime_name() -> str: class Runtime(object):
if platform.system() == "Linux": """Represents the container runtime to use.
runtime_name = "podman"
- 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: else:
# Windows, Darwin, and unknown use docker for now, dangerzone-vm eventually self.name = self.get_default_runtime_name()
runtime_name = "docker" self.path = Runtime.path_from_name(self.name)
return runtime_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() -> Tuple[int, int]: def get_runtime_version(runtime: Optional[Runtime] = None) -> Tuple[int, int]:
"""Get the major/minor parts of the Docker/Podman version. """Get the major/minor parts of the Docker/Podman version.
Some of the operations we perform in this module rely on some Podman features Some of the operations we perform in this module rely on some Podman features
@ -30,14 +64,15 @@ def get_runtime_version() -> Tuple[int, int]:
just knowing the major and minor version, since writing/installing a full-blown just knowing the major and minor version, since writing/installing a full-blown
semver parser is an overkill. semver parser is an overkill.
""" """
runtime = runtime or Runtime()
# Get the Docker/Podman version, using a Go template. # Get the Docker/Podman version, using a Go template.
runtime = get_runtime_name() if runtime.name == "podman":
if runtime == "podman":
query = "{{.Client.Version}}" query = "{{.Client.Version}}"
else: else:
query = "{{.Server.Version}}" query = "{{.Server.Version}}"
cmd = [runtime, "version", "-f", query] cmd = [str(runtime.path), "version", "-f", query]
try: try:
version = subprocess.run( version = subprocess.run(
cmd, cmd,
@ -46,7 +81,7 @@ def get_runtime_version() -> Tuple[int, int]:
check=True, check=True,
).stdout.decode() ).stdout.decode()
except Exception as e: except Exception as e:
msg = f"Could not get the version of the {runtime.capitalize()} tool: {e}" msg = f"Could not get the version of the {runtime.name.capitalize()} tool: {e}"
raise RuntimeError(msg) from e raise RuntimeError(msg) from e
# Parse this version and return the major/minor parts, since we don't need the # Parse this version and return the major/minor parts, since we don't need the
@ -56,20 +91,12 @@ def get_runtime_version() -> Tuple[int, int]:
return (int(major), int(minor)) return (int(major), int(minor))
except Exception as e: except Exception as e:
msg = ( msg = (
f"Could not parse the version of the {runtime.capitalize()} tool" f"Could not parse the version of the {runtime.name.capitalize()} tool"
f" (found: '{version}') due to the following error: {e}" f" (found: '{version}') due to the following error: {e}"
) )
raise RuntimeError(msg) raise RuntimeError(msg)
def get_runtime() -> str:
container_tech = get_runtime_name()
runtime = shutil.which(container_tech)
if runtime is None:
raise errors.NoContainerTechException(container_tech)
return runtime
def list_image_tags() -> List[str]: def list_image_tags() -> List[str]:
"""Get the tags of all loaded Dangerzone images. """Get the tags of all loaded Dangerzone images.
@ -77,10 +104,11 @@ def list_image_tags() -> List[str]:
images. This can be useful when we want to find which are the local image tags, 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. and which image ID does the "latest" tag point to.
""" """
runtime = Runtime()
return ( return (
subprocess.check_output( subprocess.check_output(
[ [
get_runtime(), str(runtime.path),
"image", "image",
"list", "list",
"--format", "--format",
@ -97,19 +125,21 @@ def list_image_tags() -> List[str]:
def add_image_tag(image_id: str, new_tag: str) -> None: def add_image_tag(image_id: str, new_tag: str) -> None:
"""Add a tag to the Dangerzone image.""" """Add a tag to the Dangerzone image."""
runtime = Runtime()
log.debug(f"Adding tag '{new_tag}' to image '{image_id}'") log.debug(f"Adding tag '{new_tag}' to image '{image_id}'")
subprocess.check_output( subprocess.check_output(
[get_runtime(), "tag", image_id, new_tag], [str(runtime.path), "tag", image_id, new_tag],
startupinfo=get_subprocess_startupinfo(), startupinfo=get_subprocess_startupinfo(),
) )
def delete_image_tag(tag: str) -> None: def delete_image_tag(tag: str) -> None:
"""Delete a Dangerzone image tag.""" """Delete a Dangerzone image tag."""
runtime = Runtime()
log.warning(f"Deleting old container image: {tag}") log.warning(f"Deleting old container image: {tag}")
try: try:
subprocess.check_output( subprocess.check_output(
[get_runtime(), "rmi", "--force", tag], [str(runtime.name), "rmi", "--force", tag],
startupinfo=get_subprocess_startupinfo(), startupinfo=get_subprocess_startupinfo(),
) )
except Exception as e: except Exception as e:
@ -121,16 +151,17 @@ def delete_image_tag(tag: str) -> None:
def get_expected_tag() -> str: def get_expected_tag() -> str:
"""Get the tag of the Dangerzone image tarball from the image-id.txt file.""" """Get the tag of the Dangerzone image tarball from the image-id.txt file."""
with open(get_resource_path("image-id.txt")) as f: with get_resource_path("image-id.txt").open() as f:
return f.read().strip() return f.read().strip()
def load_image_tarball() -> None: def load_image_tarball() -> None:
runtime = Runtime()
log.info("Installing Dangerzone container image...") log.info("Installing Dangerzone container image...")
tarball_path = get_resource_path("container.tar") tarball_path = get_resource_path("container.tar")
try: try:
res = subprocess.run( res = subprocess.run(
[get_runtime(), "load", "-i", tarball_path], [str(runtime.path), "load", "-i", str(tarball_path)],
startupinfo=get_subprocess_startupinfo(), startupinfo=get_subprocess_startupinfo(),
capture_output=True, capture_output=True,
check=True, check=True,
@ -155,7 +186,7 @@ def load_image_tarball() -> None:
# `share/image-id.txt` and delete the incorrect tag. # `share/image-id.txt` and delete the incorrect tag.
# #
# [1] https://github.com/containers/podman/issues/16490 # [1] https://github.com/containers/podman/issues/16490
if get_runtime_name() == "podman" and get_runtime_version() == (3, 4): if runtime.name == "podman" and get_runtime_version(runtime) == (3, 4):
expected_tag = get_expected_tag() expected_tag = get_expected_tag()
bad_tag = f"localhost/{expected_tag}:latest" bad_tag = f"localhost/{expected_tag}:latest"
good_tag = f"{CONTAINER_NAME}:{expected_tag}" good_tag = f"{CONTAINER_NAME}:{expected_tag}"

View file

@ -140,3 +140,7 @@ class NotAvailableContainerTechException(Exception):
self.error = error self.error = error
self.container_tech = container_tech self.container_tech = container_tech
super().__init__(f"{container_tech} is not available") super().__init__(f"{container_tech} is not available")
class UnsupportedContainerRuntime(Exception):
pass

View file

@ -51,7 +51,7 @@ class Application(QtWidgets.QApplication):
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
super(Application, self).__init__(*args, **kwargs) super(Application, self).__init__(*args, **kwargs)
self.setQuitOnLastWindowClosed(False) self.setQuitOnLastWindowClosed(False)
with open(get_resource_path("dangerzone.css"), "r") as f: with get_resource_path("dangerzone.css").open("r") as f:
style = f.read() style = f.read()
self.setStyleSheet(style) self.setStyleSheet(style)

View file

@ -63,7 +63,7 @@ class DangerzoneGui(DangerzoneCore):
path = get_resource_path("dangerzone.ico") path = get_resource_path("dangerzone.ico")
else: else:
path = get_resource_path("icon.png") path = get_resource_path("icon.png")
return QtGui.QIcon(path) return QtGui.QIcon(str(path))
def open_pdf_viewer(self, filename: str) -> None: def open_pdf_viewer(self, filename: str) -> None:
if platform.system() == "Darwin": if platform.system() == "Darwin":
@ -252,7 +252,7 @@ class Alert(Dialog):
def create_layout(self) -> QtWidgets.QBoxLayout: def create_layout(self) -> QtWidgets.QBoxLayout:
logo = QtWidgets.QLabel() logo = QtWidgets.QLabel()
logo.setPixmap( logo.setPixmap(
QtGui.QPixmap.fromImage(QtGui.QImage(get_resource_path("icon.png"))) QtGui.QPixmap.fromImage(QtGui.QImage(str(get_resource_path("icon.png"))))
) )
label = QtWidgets.QLabel() label = QtWidgets.QLabel()

View file

@ -62,7 +62,7 @@ def load_svg_image(filename: str, width: int, height: int) -> QtGui.QPixmap:
This answer is basically taken from: https://stackoverflow.com/a/25689790 This answer is basically taken from: https://stackoverflow.com/a/25689790
""" """
path = get_resource_path(filename) path = get_resource_path(filename)
svg_renderer = QtSvg.QSvgRenderer(path) svg_renderer = QtSvg.QSvgRenderer(str(path))
image = QtGui.QImage(width, height, QtGui.QImage.Format_ARGB32) image = QtGui.QImage(width, height, QtGui.QImage.Format_ARGB32)
# Set the ARGB to 0 to prevent rendering artifacts # Set the ARGB to 0 to prevent rendering artifacts
image.fill(0x00000000) image.fill(0x00000000)
@ -130,9 +130,8 @@ class MainWindow(QtWidgets.QMainWindow):
# Header # Header
logo = QtWidgets.QLabel() logo = QtWidgets.QLabel()
logo.setPixmap( icon_path = str(get_resource_path("icon.png"))
QtGui.QPixmap.fromImage(QtGui.QImage(get_resource_path("icon.png"))) logo.setPixmap(QtGui.QPixmap.fromImage(QtGui.QImage(icon_path)))
)
header_label = QtWidgets.QLabel("Dangerzone") header_label = QtWidgets.QLabel("Dangerzone")
header_label.setFont(self.dangerzone.fixed_font) header_label.setFont(self.dangerzone.fixed_font)
header_label.setStyleSheet("QLabel { font-weight: bold; font-size: 50px; }") header_label.setStyleSheet("QLabel { font-weight: bold; font-size: 50px; }")
@ -222,11 +221,14 @@ class MainWindow(QtWidgets.QMainWindow):
self.setProperty("OSColorMode", self.dangerzone.app.os_color_mode.value) self.setProperty("OSColorMode", self.dangerzone.app.os_color_mode.value)
if hasattr(self.dangerzone.isolation_provider, "check_docker_desktop_version"): if hasattr(self.dangerzone.isolation_provider, "check_docker_desktop_version"):
try:
is_version_valid, version = ( is_version_valid, version = (
self.dangerzone.isolation_provider.check_docker_desktop_version() self.dangerzone.isolation_provider.check_docker_desktop_version()
) )
if not is_version_valid: if not is_version_valid:
self.handle_docker_desktop_version_check(is_version_valid, version) self.handle_docker_desktop_version_check(is_version_valid, version)
except errors.UnsupportedContainerRuntime as e:
pass # It's catched later in the flow.
self.show() self.show()
@ -575,8 +577,15 @@ class WaitingWidgetContainer(WaitingWidget):
self.finished.emit() self.finished.emit()
def state_change(self, state: str, error: Optional[str] = None) -> None: def state_change(self, state: str, error: Optional[str] = None) -> None:
custom_runtime = self.dangerzone.settings.custom_runtime_specified()
if state == "not_installed": if state == "not_installed":
if platform.system() == "Linux": if custom_runtime:
self.show_error(
"<strong>We could not find the container runtime defined in your settings</strong><br><br>"
"Please check your settings, install it if needed, and retry."
)
elif platform.system() == "Linux":
self.show_error( self.show_error(
"<strong>Dangerzone requires Podman</strong><br><br>" "<strong>Dangerzone requires Podman</strong><br><br>"
"Install it and retry." "Install it and retry."
@ -589,19 +598,25 @@ class WaitingWidgetContainer(WaitingWidget):
) )
elif state == "not_running": elif state == "not_running":
if platform.system() == "Linux": if custom_runtime:
self.show_error(
"<strong>We were unable to start the container runtime defined in your settings</strong><br><br>"
"Please check your settings, install it if needed, and retry."
)
elif platform.system() == "Linux":
# "not_running" here means that the `podman image ls` command failed. # "not_running" here means that the `podman image ls` command failed.
message = ( self.show_error(
"<strong>Dangerzone requires Podman</strong><br><br>" "<strong>Dangerzone requires Podman</strong><br><br>"
"Podman is installed but cannot run properly. See errors below" "Podman is installed but cannot run properly. See errors below",
error,
) )
else: else:
message = ( self.show_error(
"<strong>Dangerzone requires Docker Desktop</strong><br><br>" "<strong>Dangerzone requires Docker Desktop</strong><br><br>"
"Docker is installed but isn't running.<br><br>" "Docker is installed but isn't running.<br><br>"
"Open Docker and make sure it's running in the background." "Open Docker and make sure it's running in the background.",
error,
) )
self.show_error(message, error)
else: else:
self.show_message( self.show_message(
"Installing the Dangerzone container image.<br><br>" "Installing the Dangerzone container image.<br><br>"
@ -1306,7 +1321,7 @@ class DocumentWidget(QtWidgets.QWidget):
def load_status_image(self, filename: str) -> QtGui.QPixmap: def load_status_image(self, filename: str) -> QtGui.QPixmap:
path = get_resource_path(filename) path = get_resource_path(filename)
img = QtGui.QImage(path) img = QtGui.QImage(str(path))
image = QtGui.QPixmap.fromImage(img) image = QtGui.QPixmap.fromImage(img)
return image.scaled(QtCore.QSize(15, 15)) return image.scaled(QtCore.QSize(15, 15))

View file

@ -6,6 +6,7 @@ import subprocess
from typing import List, Tuple from typing import List, Tuple
from .. import container_utils, errors from .. import container_utils, errors
from ..container_utils import Runtime
from ..document import Document from ..document import Document
from ..util import get_resource_path, get_subprocess_startupinfo from ..util import get_resource_path, get_subprocess_startupinfo
from .base import IsolationProvider, terminate_process_group from .base import IsolationProvider, terminate_process_group
@ -50,7 +51,8 @@ class Container(IsolationProvider):
* Do not map the host user to the container, with `--userns nomap` (available * Do not map the host user to the container, with `--userns nomap` (available
from Podman 4.1 onwards) from Podman 4.1 onwards)
""" """
if container_utils.get_runtime_name() == "podman": runtime = Runtime()
if runtime.name == "podman":
security_args = ["--log-driver", "none"] security_args = ["--log-driver", "none"]
security_args += ["--security-opt", "no-new-privileges"] security_args += ["--security-opt", "no-new-privileges"]
if container_utils.get_runtime_version() >= (4, 1): if container_utils.get_runtime_version() >= (4, 1):
@ -64,7 +66,7 @@ class Container(IsolationProvider):
# #
# [1] https://github.com/freedomofpress/dangerzone/issues/846 # [1] https://github.com/freedomofpress/dangerzone/issues/846
# [2] https://github.com/containers/common/blob/d3283f8401eeeb21f3c59a425b5461f069e199a7/pkg/seccomp/seccomp.json # [2] https://github.com/containers/common/blob/d3283f8401eeeb21f3c59a425b5461f069e199a7/pkg/seccomp/seccomp.json
seccomp_json_path = get_resource_path("seccomp.gvisor.json") seccomp_json_path = str(get_resource_path("seccomp.gvisor.json"))
security_args += ["--security-opt", f"seccomp={seccomp_json_path}"] security_args += ["--security-opt", f"seccomp={seccomp_json_path}"]
security_args += ["--cap-drop", "all"] security_args += ["--cap-drop", "all"]
@ -123,12 +125,11 @@ class Container(IsolationProvider):
@staticmethod @staticmethod
def is_available() -> bool: def is_available() -> bool:
container_runtime = container_utils.get_runtime() runtime = Runtime()
runtime_name = container_utils.get_runtime_name()
# Can we run `docker/podman image ls` without an error # Can we run `docker/podman image ls` without an error
with subprocess.Popen( with subprocess.Popen(
[container_runtime, "image", "ls"], [str(runtime.path), "image", "ls"],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
startupinfo=get_subprocess_startupinfo(), startupinfo=get_subprocess_startupinfo(),
@ -136,14 +137,18 @@ class Container(IsolationProvider):
_, stderr = p.communicate() _, stderr = p.communicate()
if p.returncode != 0: if p.returncode != 0:
raise errors.NotAvailableContainerTechException( raise errors.NotAvailableContainerTechException(
runtime_name, stderr.decode() runtime.name, stderr.decode()
) )
return True return True
def check_docker_desktop_version(self) -> Tuple[bool, str]: def check_docker_desktop_version(self) -> Tuple[bool, str]:
# On windows and darwin, check that the minimum version is met # On windows and darwin, check that the minimum version is met
version = "" version = ""
if platform.system() != "Linux": 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( with subprocess.Popen(
["docker", "version", "--format", "{{.Server.Platform.Name}}"], ["docker", "version", "--format", "{{.Server.Platform.Name}}"],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@ -193,7 +198,7 @@ class Container(IsolationProvider):
command: List[str], command: List[str],
name: str, name: str,
) -> subprocess.Popen: ) -> subprocess.Popen:
container_runtime = container_utils.get_runtime() runtime = Runtime()
security_args = self.get_runtime_security_args() security_args = self.get_runtime_security_args()
debug_args = [] debug_args = []
if self.debug: if self.debug:
@ -215,7 +220,7 @@ class Container(IsolationProvider):
+ image_name + image_name
+ command + command
) )
return self.exec([container_runtime] + args) return self.exec([str(runtime.path)] + args)
def kill_container(self, name: str) -> None: def kill_container(self, name: str) -> None:
"""Terminate a spawned container. """Terminate a spawned container.
@ -227,8 +232,8 @@ class Container(IsolationProvider):
connected to the Docker daemon, and killing it will just close the associated connected to the Docker daemon, and killing it will just close the associated
standard streams. standard streams.
""" """
container_runtime = container_utils.get_runtime() runtime = Runtime()
cmd = [container_runtime, "kill", name] cmd = [str(runtime.path), "kill", name]
try: try:
# We do not check the exit code of the process here, since the container may # 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 # have stopped right before invoking this command. In that case, the
@ -284,10 +289,10 @@ class Container(IsolationProvider):
# after a podman kill / docker kill invocation, this will likely be the case, # 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 # else the container runtime (Docker/Podman) has experienced a problem, and we
# should report it. # should report it.
container_runtime = container_utils.get_runtime() runtime = Runtime()
name = self.doc_to_pixels_container_name(document) name = self.doc_to_pixels_container_name(document)
all_containers = subprocess.run( all_containers = subprocess.run(
[container_runtime, "ps", "-a"], [str(runtime.path), "ps", "-a"],
capture_output=True, capture_output=True,
startupinfo=get_subprocess_startupinfo(), startupinfo=get_subprocess_startupinfo(),
) )
@ -298,19 +303,20 @@ class Container(IsolationProvider):
# FIXME hardcoded 1 until length conversions are better handled # FIXME hardcoded 1 until length conversions are better handled
# https://github.com/freedomofpress/dangerzone/issues/257 # https://github.com/freedomofpress/dangerzone/issues/257
return 1 return 1
runtime = Runtime() # type: ignore [unreachable]
n_cpu = 1 # type: ignore [unreachable] n_cpu = 1
if platform.system() == "Linux": if platform.system() == "Linux":
# if on linux containers run natively # if on linux containers run natively
cpu_count = os.cpu_count() cpu_count = os.cpu_count()
if cpu_count is not None: if cpu_count is not None:
n_cpu = cpu_count n_cpu = cpu_count
elif container_utils.get_runtime_name() == "docker": elif runtime.name == "docker":
# For Windows and MacOS containers run in VM # For Windows and MacOS containers run in VM
# So we obtain the CPU count for the VM # So we obtain the CPU count for the VM
n_cpu_str = subprocess.check_output( n_cpu_str = subprocess.check_output(
[container_utils.get_runtime(), "info", "--format", "{{.NCPU}}"], [str(runtime.path), "info", "--format", "{{.NCPU}}"],
text=True, text=True,
startupinfo=get_subprocess_startupinfo(), startupinfo=get_subprocess_startupinfo(),
) )

View file

@ -130,7 +130,6 @@ def is_qubes_native_conversion() -> bool:
# This disambiguates if it is running a Qubes targetted build or not # This disambiguates if it is running a Qubes targetted build or not
# (Qubes-specific builds don't ship the container image) # (Qubes-specific builds don't ship the container image)
container_image_path = get_resource_path("container.tar") return not get_resource_path("container.tar").exists()
return not os.path.exists(container_image_path)
else: else:
return False return False

View file

@ -23,16 +23,13 @@ class DangerzoneCore(object):
# Initialize terminal colors # Initialize terminal colors
colorama.init(autoreset=True) colorama.init(autoreset=True)
# App data folder
self.appdata_path = util.get_config_dir()
# Languages supported by tesseract # Languages supported by tesseract
with open(get_resource_path("ocr-languages.json"), "r") as f: with get_resource_path("ocr-languages.json").open("r") as f:
unsorted_ocr_languages = json.load(f) unsorted_ocr_languages = json.load(f)
self.ocr_languages = dict(sorted(unsorted_ocr_languages.items())) self.ocr_languages = dict(sorted(unsorted_ocr_languages.items()))
# Load settings # Load settings
self.settings = Settings(self) self.settings = Settings()
self.documents: List[Document] = [] self.documents: List[Document] = []
self.isolation_provider = isolation_provider self.isolation_provider = isolation_provider

View file

@ -1,29 +1,24 @@
import json import json
import logging import logging
import os import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict from typing import TYPE_CHECKING, Any, Dict
from packaging import version from packaging import version
from .document import SAFE_EXTENSION from .document import SAFE_EXTENSION
from .util import get_version from .util import get_config_dir, get_version
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
if TYPE_CHECKING:
from .logic import DangerzoneCore
SETTINGS_FILENAME: str = "settings.json" SETTINGS_FILENAME: str = "settings.json"
class Settings: class Settings:
settings: Dict[str, Any] settings: Dict[str, Any]
def __init__(self, dangerzone: "DangerzoneCore") -> None: def __init__(self) -> None:
self.dangerzone = dangerzone self.settings_filename = get_config_dir() / SETTINGS_FILENAME
self.settings_filename = os.path.join(
self.dangerzone.appdata_path, SETTINGS_FILENAME
)
self.default_settings: Dict[str, Any] = self.generate_default_settings() self.default_settings: Dict[str, Any] = self.generate_default_settings()
self.load() self.load()
@ -45,6 +40,18 @@ class Settings:
"updater_errors": 0, "updater_errors": 0,
} }
def custom_runtime_specified(self) -> bool:
return "container_runtime" in self.settings
def set_custom_runtime(self, runtime: str, autosave: bool = False) -> Path:
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 get(self, key: str) -> Any: def get(self, key: str) -> Any:
return self.settings[key] return self.settings[key]
@ -91,6 +98,6 @@ class Settings:
self.save() self.save()
def save(self) -> None: def save(self) -> None:
os.makedirs(self.dangerzone.appdata_path, exist_ok=True) self.settings_filename.parent.mkdir(parents=True, exist_ok=True)
with open(self.settings_filename, "w") as settings_file: with self.settings_filename.open("w") as settings_file:
json.dump(self.settings, settings_file, indent=4) json.dump(self.settings, settings_file, indent=4)

View file

@ -1,9 +1,9 @@
import pathlib
import platform import platform
import subprocess import subprocess
import sys import sys
import traceback import traceback
import unicodedata import unicodedata
from pathlib import Path
try: try:
import platformdirs import platformdirs
@ -11,40 +11,39 @@ except ImportError:
import appdirs as platformdirs import appdirs as platformdirs
def get_config_dir() -> str: def get_config_dir() -> Path:
return platformdirs.user_config_dir("dangerzone") return Path(platformdirs.user_config_dir("dangerzone"))
def get_resource_path(filename: str) -> str: def get_resource_path(filename: str) -> Path:
if getattr(sys, "dangerzone_dev", False): if getattr(sys, "dangerzone_dev", False):
# Look for resources directory relative to python file # Look for resources directory relative to python file
project_root = pathlib.Path(__file__).parent.parent project_root = Path(__file__).parent.parent
prefix = project_root / "share" prefix = project_root / "share"
else: else:
if platform.system() == "Darwin": if platform.system() == "Darwin":
bin_path = pathlib.Path(sys.executable) bin_path = Path(sys.executable)
app_path = bin_path.parent.parent app_path = bin_path.parent.parent
prefix = app_path / "Resources" / "share" prefix = app_path / "Resources" / "share"
elif platform.system() == "Linux": elif platform.system() == "Linux":
prefix = pathlib.Path(sys.prefix) / "share" / "dangerzone" prefix = Path(sys.prefix) / "share" / "dangerzone"
elif platform.system() == "Windows": elif platform.system() == "Windows":
exe_path = pathlib.Path(sys.executable) exe_path = Path(sys.executable)
dz_install_path = exe_path.parent dz_install_path = exe_path.parent
prefix = dz_install_path / "share" prefix = dz_install_path / "share"
else: else:
raise NotImplementedError(f"Unsupported system {platform.system()}") raise NotImplementedError(f"Unsupported system {platform.system()}")
resource_path = prefix / filename return prefix / filename
return str(resource_path)
def get_tessdata_dir() -> pathlib.Path: def get_tessdata_dir() -> Path:
if getattr(sys, "dangerzone_dev", False) or platform.system() in ( if getattr(sys, "dangerzone_dev", False) or platform.system() in (
"Windows", "Windows",
"Darwin", "Darwin",
): ):
# Always use the tessdata path from the Dangerzone ./share directory, for # Always use the tessdata path from the Dangerzone ./share directory, for
# development builds, or in Windows/macOS platforms. # development builds, or in Windows/macOS platforms.
return pathlib.Path(get_resource_path("tessdata")) return get_resource_path("tessdata")
# In case of Linux systems, grab the Tesseract data from any of the following # 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 # locations. We have found some of the locations through trial and error, whereas
@ -55,11 +54,11 @@ def get_tessdata_dir() -> pathlib.Path:
# #
# [1] https://tesseract-ocr.github.io/tessdoc/Installation.html # [1] https://tesseract-ocr.github.io/tessdoc/Installation.html
tessdata_dirs = [ tessdata_dirs = [
pathlib.Path("/usr/share/tessdata/"), # on some Debian Path("/usr/share/tessdata/"), # on some Debian
pathlib.Path("/usr/share/tesseract/tessdata/"), # on Fedora Path("/usr/share/tesseract/tessdata/"), # on Fedora
pathlib.Path("/usr/share/tesseract-ocr/tessdata/"), # ? (documented) Path("/usr/share/tesseract-ocr/tessdata/"), # ? (documented)
pathlib.Path("/usr/share/tesseract-ocr/4.00/tessdata/"), # on Debian Bullseye Path("/usr/share/tesseract-ocr/4.00/tessdata/"), # on Debian Bullseye
pathlib.Path("/usr/share/tesseract-ocr/5/tessdata/"), # on Debian Trixie Path("/usr/share/tesseract-ocr/5/tessdata/"), # on Debian Trixie
] ]
for dir in tessdata_dirs: for dir in tessdata_dirs:
@ -71,7 +70,7 @@ def get_tessdata_dir() -> pathlib.Path:
def get_version() -> str: def get_version() -> str:
try: try:
with open(get_resource_path("version.txt")) as f: with get_resource_path("version.txt").open() as f:
version = f.read().strip() version = f.read().strip()
except FileNotFoundError: except FileNotFoundError:
# In dev mode, in Windows, get_resource_path doesn't work properly for the container, but luckily # In dev mode, in Windows, get_resource_path doesn't work properly for the container, but luckily

View file

@ -21,34 +21,25 @@ def get_qt_app() -> Application:
def generate_isolated_updater( def generate_isolated_updater(
tmp_path: Path, tmp_path: Path,
monkeypatch: MonkeyPatch, mocker: MockerFixture,
app_mocker: Optional[MockerFixture] = None, mock_app: bool = False,
) -> UpdaterThread: ) -> UpdaterThread:
"""Generate an Updater class with its own settings.""" """Generate an Updater class with its own settings."""
if app_mocker: app = mocker.MagicMock() if mock_app else get_qt_app()
app = app_mocker.MagicMock()
else:
app = get_qt_app()
dummy = Dummy() dummy = Dummy()
# XXX: We can monkey-patch global state without wrapping it in a context manager, or mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
# worrying that it will leak between tests, for two reasons:
#
# 1. Parallel tests in PyTest take place in different processes.
# 2. The monkeypatch fixture tears down the monkey-patch after each test ends.
monkeypatch.setattr(util, "get_config_dir", lambda: tmp_path)
dangerzone = DangerzoneGui(app, isolation_provider=dummy) dangerzone = DangerzoneGui(app, isolation_provider=dummy)
updater = UpdaterThread(dangerzone) updater = UpdaterThread(dangerzone)
return updater return updater
@pytest.fixture @pytest.fixture
def updater( def updater(tmp_path: Path, mocker: MockerFixture) -> UpdaterThread:
tmp_path: Path, monkeypatch: MonkeyPatch, mocker: MockerFixture return generate_isolated_updater(tmp_path, mocker, mock_app=True)
) -> UpdaterThread:
return generate_isolated_updater(tmp_path, monkeypatch, mocker)
@pytest.fixture @pytest.fixture
def qt_updater(tmp_path: Path, monkeypatch: MonkeyPatch) -> UpdaterThread: def qt_updater(tmp_path: Path, mocker: MockerFixture) -> UpdaterThread:
return generate_isolated_updater(tmp_path, monkeypatch) return generate_isolated_updater(tmp_path, mocker, mock_app=False)

View file

@ -48,9 +48,7 @@ def test_default_updater_settings(updater: UpdaterThread) -> None:
) )
def test_pre_0_4_2_settings( def test_pre_0_4_2_settings(tmp_path: Path, mocker: MockerFixture) -> None:
tmp_path: Path, monkeypatch: MonkeyPatch, mocker: MockerFixture
) -> None:
"""Check settings of installations prior to 0.4.2. """Check settings of installations prior to 0.4.2.
Check that installations that have been upgraded from a version < 0.4.2 to >= 0.4.2 Check that installations that have been upgraded from a version < 0.4.2 to >= 0.4.2
@ -58,7 +56,7 @@ def test_pre_0_4_2_settings(
in their settings.json file. in their settings.json file.
""" """
save_settings(tmp_path, default_settings_0_4_1()) save_settings(tmp_path, default_settings_0_4_1())
updater = generate_isolated_updater(tmp_path, monkeypatch, mocker) updater = generate_isolated_updater(tmp_path, mocker, mock_app=True)
assert ( assert (
updater.dangerzone.settings.get_updater_settings() == default_updater_settings() updater.dangerzone.settings.get_updater_settings() == default_updater_settings()
) )
@ -83,12 +81,10 @@ def test_post_0_4_2_settings(
# version is 0.4.3. # version is 0.4.3.
expected_settings = default_updater_settings() expected_settings = default_updater_settings()
expected_settings["updater_latest_version"] = "0.4.3" expected_settings["updater_latest_version"] = "0.4.3"
monkeypatch.setattr( monkeypatch.setattr(settings, "get_version", lambda: "0.4.3")
settings, "get_version", lambda: expected_settings["updater_latest_version"]
)
# Ensure that the Settings class will correct the latest version field to 0.4.3. # Ensure that the Settings class will correct the latest version field to 0.4.3.
updater = generate_isolated_updater(tmp_path, monkeypatch, mocker) updater = generate_isolated_updater(tmp_path, mocker, mock_app=True)
assert updater.dangerzone.settings.get_updater_settings() == expected_settings assert updater.dangerzone.settings.get_updater_settings() == expected_settings
# Simulate an updater check that found a newer Dangerzone version (e.g., 0.4.4). # Simulate an updater check that found a newer Dangerzone version (e.g., 0.4.4).
@ -118,9 +114,7 @@ def test_linux_no_check(updater: UpdaterThread, monkeypatch: MonkeyPatch) -> Non
assert updater.dangerzone.settings.get_updater_settings() == expected_settings assert updater.dangerzone.settings.get_updater_settings() == expected_settings
def test_user_prompts( def test_user_prompts(updater: UpdaterThread, mocker: MockerFixture) -> None:
updater: UpdaterThread, monkeypatch: MonkeyPatch, mocker: MockerFixture
) -> None:
"""Test prompting users to ask them if they want to enable update checks.""" """Test prompting users to ask them if they want to enable update checks."""
# First run # First run
# #
@ -370,8 +364,6 @@ def test_update_errors(
def test_update_check_prompt( def test_update_check_prompt(
qtbot: QtBot, qtbot: QtBot,
qt_updater: UpdaterThread, qt_updater: UpdaterThread,
monkeypatch: MonkeyPatch,
mocker: MockerFixture,
) -> None: ) -> None:
"""Test that the prompt to enable update checks works properly.""" """Test that the prompt to enable update checks works properly."""
# Force Dangerzone to check immediately for updates # Force Dangerzone to check immediately for updates

View file

@ -5,7 +5,8 @@ import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from pytest_subprocess import FakeProcess from pytest_subprocess import FakeProcess
from dangerzone import container_utils, errors from dangerzone import errors
from dangerzone.container_utils import Runtime
from dangerzone.isolation_provider.container import Container from dangerzone.isolation_provider.container import Container
from dangerzone.isolation_provider.qubes import is_qubes_native_conversion from dangerzone.isolation_provider.qubes import is_qubes_native_conversion
from dangerzone.util import get_resource_path from dangerzone.util import get_resource_path
@ -24,42 +25,51 @@ def provider() -> Container:
return Container() return Container()
@pytest.fixture
def runtime_path() -> str:
return str(Runtime().path)
class TestContainer(IsolationProviderTest): class TestContainer(IsolationProviderTest):
def test_is_available_raises(self, provider: Container, fp: FakeProcess) -> None: def test_is_available_raises(
self, provider: Container, fp: FakeProcess, runtime_path: str
) -> None:
""" """
NotAvailableContainerTechException should be raised when NotAvailableContainerTechException should be raised when
the "podman image ls" command fails. the "podman image ls" command fails.
""" """
fp.register_subprocess( fp.register_subprocess(
[container_utils.get_runtime(), "image", "ls"], [runtime_path, "image", "ls"],
returncode=-1, returncode=-1,
stderr="podman image ls logs", stderr="podman image ls logs",
) )
with pytest.raises(errors.NotAvailableContainerTechException): with pytest.raises(errors.NotAvailableContainerTechException):
provider.is_available() provider.is_available()
def test_is_available_works(self, provider: Container, fp: FakeProcess) -> None: def test_is_available_works(
self, provider: Container, fp: FakeProcess, runtime_path: str
) -> None:
""" """
No exception should be raised when the "podman image ls" can return properly. No exception should be raised when the "podman image ls" can return properly.
""" """
fp.register_subprocess( fp.register_subprocess(
[container_utils.get_runtime(), "image", "ls"], [runtime_path, "image", "ls"],
) )
provider.is_available() provider.is_available()
def test_install_raise_if_image_cant_be_installed( def test_install_raise_if_image_cant_be_installed(
self, provider: Container, fp: FakeProcess self, provider: Container, fp: FakeProcess, runtime_path: str
) -> None: ) -> None:
"""When an image installation fails, an exception should be raised""" """When an image installation fails, an exception should be raised"""
fp.register_subprocess( fp.register_subprocess(
[container_utils.get_runtime(), "image", "ls"], [runtime_path, "image", "ls"],
) )
# First check should return nothing. # First check should return nothing.
fp.register_subprocess( fp.register_subprocess(
[ [
container_utils.get_runtime(), runtime_path,
"image", "image",
"list", "list",
"--format", "--format",
@ -71,10 +81,10 @@ class TestContainer(IsolationProviderTest):
fp.register_subprocess( fp.register_subprocess(
[ [
container_utils.get_runtime(), runtime_path,
"load", "load",
"-i", "-i",
get_resource_path("container.tar"), get_resource_path("container.tar").absolute(),
], ],
returncode=-1, returncode=-1,
) )
@ -83,22 +93,22 @@ class TestContainer(IsolationProviderTest):
provider.install() provider.install()
def test_install_raises_if_still_not_installed( def test_install_raises_if_still_not_installed(
self, provider: Container, fp: FakeProcess self, provider: Container, fp: FakeProcess, runtime_path: str
) -> None: ) -> None:
"""When an image keep being not installed, it should return False""" """When an image keep being not installed, it should return False"""
fp.register_subprocess( fp.register_subprocess(
["podman", "version", "-f", "{{.Client.Version}}"], [runtime_path, "version", "-f", "{{.Client.Version}}"],
stdout="4.0.0", stdout="4.0.0",
) )
fp.register_subprocess( fp.register_subprocess(
[container_utils.get_runtime(), "image", "ls"], [runtime_path, "image", "ls"],
) )
# First check should return nothing. # First check should return nothing.
fp.register_subprocess( fp.register_subprocess(
[ [
container_utils.get_runtime(), runtime_path,
"image", "image",
"list", "list",
"--format", "--format",
@ -110,10 +120,10 @@ class TestContainer(IsolationProviderTest):
fp.register_subprocess( fp.register_subprocess(
[ [
container_utils.get_runtime(), runtime_path,
"load", "load",
"-i", "-i",
get_resource_path("container.tar"), get_resource_path("container.tar").absolute(),
], ],
) )
with pytest.raises(errors.ImageNotPresentException): with pytest.raises(errors.ImageNotPresentException):

View file

@ -0,0 +1,60 @@
from pathlib import Path
import pytest
from pytest_mock import MockerFixture
from dangerzone import errors
from dangerzone.container_utils import Runtime
from dangerzone.settings import Settings
def test_get_runtime_name_from_settings(mocker: MockerFixture, tmp_path: Path) -> None:
mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
mocker.patch("dangerzone.container_utils.Path.exists", return_value=True)
settings = Settings()
settings.set("container_runtime", "/opt/somewhere/docker", autosave=True)
assert Runtime().name == "docker"
def test_get_runtime_name_linux(mocker: MockerFixture, tmp_path: Path) -> None:
mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
mocker.patch("platform.system", return_value="Linux")
mocker.patch(
"dangerzone.container_utils.shutil.which", return_value="/usr/bin/podman"
)
mocker.patch("dangerzone.container_utils.os.path.exists", return_value=True)
runtime = Runtime()
assert runtime.name == "podman"
assert runtime.path == Path("/usr/bin/podman")
def test_get_runtime_name_non_linux(mocker: MockerFixture, tmp_path: Path) -> None:
mocker.patch("platform.system", return_value="Windows")
mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
mocker.patch(
"dangerzone.container_utils.shutil.which", return_value="/usr/bin/docker"
)
mocker.patch("dangerzone.container_utils.os.path.exists", return_value=True)
runtime = Runtime()
assert runtime.name == "docker"
assert runtime.path == Path("/usr/bin/docker")
mocker.patch("platform.system", return_value="Something else")
runtime = Runtime()
assert runtime.name == "docker"
assert runtime.path == Path("/usr/bin/docker")
assert Runtime().name == "docker"
def test_get_unsupported_runtime_name(mocker: MockerFixture, tmp_path: Path) -> None:
mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
settings = Settings()
settings.set(
"container_runtime", "/opt/somewhere/new-kid-on-the-block", autosave=True
)
with pytest.raises(errors.UnsupportedContainerRuntime):
assert Runtime().name == "new-kid-on-the-block"

View file

@ -1,5 +1,4 @@
import json import json
import os
from pathlib import Path from pathlib import Path
from unittest.mock import PropertyMock from unittest.mock import PropertyMock
@ -22,13 +21,6 @@ def default_settings_0_4_1() -> dict:
} }
@pytest.fixture
def settings(tmp_path: Path, mocker: MockerFixture) -> Settings:
dz_core = mocker.MagicMock()
type(dz_core).appdata_path = PropertyMock(return_value=tmp_path)
return Settings(dz_core)
def save_settings(tmp_path: Path, settings: dict) -> None: def save_settings(tmp_path: Path, settings: dict) -> None:
"""Mimic the way Settings save a dictionary to a settings.json file.""" """Mimic the way Settings save a dictionary to a settings.json file."""
settings_filename = tmp_path / "settings.json" settings_filename = tmp_path / "settings.json"
@ -36,10 +28,17 @@ def save_settings(tmp_path: Path, settings: dict) -> None:
json.dump(settings, settings_file, indent=4) json.dump(settings, settings_file, indent=4)
def test_no_settings_file_creates_new_one(settings: Settings) -> None: def test_no_settings_file_creates_new_one(
tmp_path: Path,
mocker: MockerFixture,
) -> None:
"""Default settings file is created on first run""" """Default settings file is created on first run"""
assert os.path.isfile(settings.settings_filename) mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
new_settings_dict = json.load(open(settings.settings_filename)) settings = Settings()
assert settings.settings_filename.is_file()
with settings.settings_filename.open() as settings_file:
new_settings_dict = json.load(settings_file)
assert sorted(new_settings_dict.items()) == sorted( assert sorted(new_settings_dict.items()) == sorted(
settings.generate_default_settings().items() settings.generate_default_settings().items()
) )
@ -48,14 +47,12 @@ def test_no_settings_file_creates_new_one(settings: Settings) -> None:
def test_corrupt_settings(tmp_path: Path, mocker: MockerFixture) -> None: def test_corrupt_settings(tmp_path: Path, mocker: MockerFixture) -> None:
# Set some broken settings file # Set some broken settings file
corrupt_settings_dict = "{:}" corrupt_settings_dict = "{:}"
with open(tmp_path / SETTINGS_FILENAME, "w") as settings_file: with (tmp_path / SETTINGS_FILENAME).open("w") as settings_file:
settings_file.write(corrupt_settings_dict) settings_file.write(corrupt_settings_dict)
# Initialize settings mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
dz_core = mocker.MagicMock() settings = Settings()
type(dz_core).appdata_path = PropertyMock(return_value=tmp_path) assert settings.settings_filename.is_file()
settings = Settings(dz_core)
assert os.path.isfile(settings.settings_filename)
# Check if settings file was reset to the default # Check if settings file was reset to the default
new_settings_dict = json.load(open(settings.settings_filename)) new_settings_dict = json.load(open(settings.settings_filename))
@ -66,10 +63,7 @@ def test_corrupt_settings(tmp_path: Path, mocker: MockerFixture) -> None:
def test_new_default_setting(tmp_path: Path, mocker: MockerFixture) -> None: def test_new_default_setting(tmp_path: Path, mocker: MockerFixture) -> None:
# Initialize settings settings = Settings()
dz_core = mocker.MagicMock()
type(dz_core).appdata_path = PropertyMock(return_value=tmp_path)
settings = Settings(dz_core)
settings.save() settings.save()
# Ensure new default setting is imported into settings # Ensure new default setting is imported into settings
@ -78,15 +72,12 @@ def test_new_default_setting(tmp_path: Path, mocker: MockerFixture) -> None:
return_value={"mock_setting": 1}, return_value={"mock_setting": 1},
) )
settings2 = Settings(dz_core) settings2 = Settings()
assert settings2.get("mock_setting") == 1 assert settings2.get("mock_setting") == 1
def test_new_settings_added(tmp_path: Path, mocker: MockerFixture) -> None: def test_new_settings_added(tmp_path: Path, mocker: MockerFixture) -> None:
# Initialize settings settings = Settings()
dz_core = mocker.MagicMock()
type(dz_core).appdata_path = PropertyMock(return_value=tmp_path)
settings = Settings(dz_core)
# Add new setting # Add new setting
settings.set("new_setting_autosaved", 20, autosave=True) settings.set("new_setting_autosaved", 20, autosave=True)
@ -95,7 +86,7 @@ def test_new_settings_added(tmp_path: Path, mocker: MockerFixture) -> None:
) # XXX has to be afterwards; otherwise this will be saved ) # XXX has to be afterwards; otherwise this will be saved
# Simulate new app startup (settings recreation) # Simulate new app startup (settings recreation)
settings2 = Settings(dz_core) settings2 = Settings()
# Check if new setting persisted # Check if new setting persisted
assert 20 == settings2.get("new_setting_autosaved") assert 20 == settings2.get("new_setting_autosaved")

View file

@ -11,7 +11,7 @@ VERSION_FILE_NAME = "version.txt"
def test_get_resource_path() -> None: def test_get_resource_path() -> None:
share_dir = Path("share").resolve() share_dir = Path("share").resolve()
resource_path = Path(util.get_resource_path(VERSION_FILE_NAME)).parent resource_path = util.get_resource_path(VERSION_FILE_NAME).parent
assert share_dir.samefile(resource_path), ( assert share_dir.samefile(resource_path), (
f"{share_dir} is not the same file as {resource_path}" f"{share_dir} is not the same file as {resource_path}"
) )