Compare commits

..

1 commit

Author SHA1 Message Date
sudoforge
d28d6f9479
Use an image digest to improve container image determinism
66600f32dc introduced various improvements
to the determinism of the container image in this repository. This
change builds on this effort by introducing support for a container
image digest. Image digests are immutable references, unlike tags, which
are mutable (except when optionally configured as immutable in certain
container registries, but not `docker.io`).
2025-03-30 10:26:19 -07:00
22 changed files with 191 additions and 327 deletions

View file

@ -16,20 +16,6 @@ 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)
- 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))
### Changed
- 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.
## [0.8.1](https://github.com/freedomofpress/dangerzone/compare/v0.8.1...0.8.0)
- Update the container image

View file

@ -2,9 +2,10 @@
# Dockerfile args below. For more info about this file, read
# docs/developer/reproducibility.md.
ARG DEBIAN_IMAGE_DATE=20250224
ARG DEBIAN_IMAGE_DIGEST=sha256:12c396bd585df7ec21d5679bb6a83d4878bc4415ce926c9e5ea6426d23c60bdc
FROM debian@${DEBIAN_IMAGE_DIGEST} AS dangerzone-image
FROM debian:bookworm-${DEBIAN_IMAGE_DATE}-slim@${DEBIAN_IMAGE_DIGEST} AS dangerzone-image
ARG GVISOR_ARCHIVE_DATE=20250217
ARG DEBIAN_ARCHIVE_DATE=20250226

View file

@ -1,7 +1,6 @@
# 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-20250224-slim
# Can be bumped to the latest date in https://hub.docker.com/_/debian/tags?name=bookworm-
DEBIAN_IMAGE_DATE=20250224
# Should be the INDEX DIGEST for the tag with the selected build date
DEBIAN_IMAGE_DIGEST=sha256:12c396bd585df7ec21d5679bb6a83d4878bc4415ce926c9e5ea6426d23c60bdc
# Can be bumped to today's date
DEBIAN_ARCHIVE_DATE=20250226

View file

@ -2,9 +2,10 @@
# Dockerfile args below. For more info about this file, read
# docs/developer/reproducibility.md.
ARG DEBIAN_IMAGE_DATE={{DEBIAN_IMAGE_DATE}}
ARG DEBIAN_IMAGE_DIGEST={{DEBIAN_IMAGE_DIGEST}}
FROM debian@${DEBIAN_IMAGE_DIGEST} AS dangerzone-image
FROM debian:bookworm-${DEBIAN_IMAGE_DATE}-slim@${DEBIAN_IMAGE_DIGEST} AS dangerzone-image
ARG GVISOR_ARCHIVE_DATE={{GVISOR_ARCHIVE_DATE}}
ARG DEBIAN_ARCHIVE_DATE={{DEBIAN_ARCHIVE_DATE}}

View file

@ -11,7 +11,6 @@ from .isolation_provider.container import Container
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
@ -38,7 +37,7 @@ def print_header(s: str) -> None:
)
@click.argument(
"filenames",
required=False,
required=True,
nargs=-1,
type=click.UNPROCESSED,
callback=args.validate_input_filenames,
@ -49,33 +48,17 @@ def print_header(s: str) -> None:
flag_value=True,
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")
@errors.handle_document_errors
def cli_main(
output_filename: Optional[str],
ocr_lang: Optional[str],
filenames: Optional[List[str]],
filenames: List[str],
archive: bool,
dummy_conversion: bool,
debug: bool,
set_container_runtime: Optional[str] = None,
) -> None:
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:
dangerzone = DangerzoneCore(Dummy())
@ -84,6 +67,7 @@ def cli_main(
else:
dangerzone = DangerzoneCore(Container(debug=debug))
display_banner()
if len(filenames) == 1 and output_filename:
dangerzone.add_document_from_filename(filenames[0], output_filename, archive)
elif len(filenames) > 1 and output_filename:
@ -336,10 +320,4 @@ def display_banner() -> None:
+ Style.DIM
+ ""
)
print(
Back.BLACK
+ Fore.YELLOW
+ Style.DIM
+ "╰──────────────────────────╯"
+ Style.RESET_ALL
)
print(Back.BLACK + Fore.YELLOW + Style.DIM + "╰──────────────────────────╯")

View file

@ -1,13 +1,10 @@
import logging
import os
import platform
import shutil
import subprocess
from pathlib import Path
from typing import List, Optional, Tuple
from typing import List, Tuple
from . import errors
from .settings import Settings
from .util import get_resource_path, get_subprocess_startupinfo
CONTAINER_NAME = "dangerzone.rocks/dangerzone"
@ -15,47 +12,16 @@ 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_name() -> str:
if platform.system() == "Linux":
runtime_name = "podman"
else:
# Windows, Darwin, and unknown use docker for now, dangerzone-vm eventually
runtime_name = "docker"
return runtime_name
def get_runtime_version(runtime: Optional[Runtime] = None) -> Tuple[int, int]:
def get_runtime_version() -> 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
@ -64,15 +30,14 @@ def get_runtime_version(runtime: Optional[Runtime] = None) -> Tuple[int, int]:
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":
runtime = get_runtime_name()
if runtime == "podman":
query = "{{.Client.Version}}"
else:
query = "{{.Server.Version}}"
cmd = [str(runtime.path), "version", "-f", query]
cmd = [runtime, "version", "-f", query]
try:
version = subprocess.run(
cmd,
@ -81,7 +46,7 @@ def get_runtime_version(runtime: Optional[Runtime] = None) -> Tuple[int, int]:
check=True,
).stdout.decode()
except Exception as e:
msg = f"Could not get the version of the {runtime.name.capitalize()} tool: {e}"
msg = f"Could not get the version of the {runtime.capitalize()} tool: {e}"
raise RuntimeError(msg) from e
# Parse this version and return the major/minor parts, since we don't need the
@ -91,12 +56,20 @@ def get_runtime_version(runtime: Optional[Runtime] = None) -> Tuple[int, int]:
return (int(major), int(minor))
except Exception as e:
msg = (
f"Could not parse the version of the {runtime.name.capitalize()} tool"
f"Could not parse the version of the {runtime.capitalize()} tool"
f" (found: '{version}') due to the following error: {e}"
)
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]:
"""Get the tags of all loaded Dangerzone images.
@ -104,11 +77,10 @@ def list_image_tags() -> List[str]:
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),
get_runtime(),
"image",
"list",
"--format",
@ -125,21 +97,19 @@ def list_image_tags() -> List[str]:
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],
[get_runtime(), "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],
[get_runtime(), "rmi", "--force", tag],
startupinfo=get_subprocess_startupinfo(),
)
except Exception as e:
@ -151,17 +121,16 @@ def delete_image_tag(tag: str) -> None:
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:
with open(get_resource_path("image-id.txt")) 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)],
[get_runtime(), "load", "-i", tarball_path],
startupinfo=get_subprocess_startupinfo(),
capture_output=True,
check=True,
@ -186,7 +155,7 @@ def load_image_tarball() -> None:
# `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):
if get_runtime_name() == "podman" and get_runtime_version() == (3, 4):
expected_tag = get_expected_tag()
bad_tag = f"localhost/{expected_tag}:latest"
good_tag = f"{CONTAINER_NAME}:{expected_tag}"

View file

@ -140,7 +140,3 @@ class NotAvailableContainerTechException(Exception):
self.error = error
self.container_tech = container_tech
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:
super(Application, self).__init__(*args, **kwargs)
self.setQuitOnLastWindowClosed(False)
with get_resource_path("dangerzone.css").open("r") as f:
with open(get_resource_path("dangerzone.css"), "r") as f:
style = f.read()
self.setStyleSheet(style)

View file

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

View file

@ -6,7 +6,6 @@ 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
@ -51,8 +50,7 @@ class Container(IsolationProvider):
* Do not map the host user to the container, with `--userns nomap` (available
from Podman 4.1 onwards)
"""
runtime = Runtime()
if runtime.name == "podman":
if container_utils.get_runtime_name() == "podman":
security_args = ["--log-driver", "none"]
security_args += ["--security-opt", "no-new-privileges"]
if container_utils.get_runtime_version() >= (4, 1):
@ -66,7 +64,7 @@ class Container(IsolationProvider):
#
# [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"))
seccomp_json_path = get_resource_path("seccomp.gvisor.json")
security_args += ["--security-opt", f"seccomp={seccomp_json_path}"]
security_args += ["--cap-drop", "all"]
@ -125,11 +123,12 @@ class Container(IsolationProvider):
@staticmethod
def is_available() -> bool:
runtime = Runtime()
container_runtime = container_utils.get_runtime()
runtime_name = container_utils.get_runtime_name()
# Can we run `docker/podman image ls` without an error
with subprocess.Popen(
[str(runtime.path), "image", "ls"],
[container_runtime, "image", "ls"],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
startupinfo=get_subprocess_startupinfo(),
@ -137,18 +136,14 @@ class Container(IsolationProvider):
_, stderr = p.communicate()
if p.returncode != 0:
raise errors.NotAvailableContainerTechException(
runtime.name, stderr.decode()
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:
if platform.system() != "Linux":
with subprocess.Popen(
["docker", "version", "--format", "{{.Server.Platform.Name}}"],
stdout=subprocess.PIPE,
@ -198,7 +193,7 @@ class Container(IsolationProvider):
command: List[str],
name: str,
) -> subprocess.Popen:
runtime = Runtime()
container_runtime = container_utils.get_runtime()
security_args = self.get_runtime_security_args()
debug_args = []
if self.debug:
@ -220,7 +215,7 @@ class Container(IsolationProvider):
+ image_name
+ command
)
return self.exec([str(runtime.path)] + args)
return self.exec([container_runtime] + args)
def kill_container(self, name: str) -> None:
"""Terminate a spawned container.
@ -232,8 +227,8 @@ class Container(IsolationProvider):
connected to the Docker daemon, and killing it will just close the associated
standard streams.
"""
runtime = Runtime()
cmd = [str(runtime.path), "kill", name]
container_runtime = container_utils.get_runtime()
cmd = [container_runtime, "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
@ -289,10 +284,10 @@ class Container(IsolationProvider):
# 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()
container_runtime = container_utils.get_runtime()
name = self.doc_to_pixels_container_name(document)
all_containers = subprocess.run(
[str(runtime.path), "ps", "-a"],
[container_runtime, "ps", "-a"],
capture_output=True,
startupinfo=get_subprocess_startupinfo(),
)
@ -303,20 +298,19 @@ class Container(IsolationProvider):
# 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
n_cpu = 1 # type: ignore [unreachable]
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":
elif container_utils.get_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}}"],
[container_utils.get_runtime(), "info", "--format", "{{.NCPU}}"],
text=True,
startupinfo=get_subprocess_startupinfo(),
)

View file

@ -130,6 +130,7 @@ def is_qubes_native_conversion() -> bool:
# 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()
container_image_path = get_resource_path("container.tar")
return not os.path.exists(container_image_path)
else:
return False

View file

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

View file

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

View file

@ -1,9 +1,9 @@
import pathlib
import platform
import subprocess
import sys
import traceback
import unicodedata
from pathlib import Path
try:
import platformdirs
@ -11,39 +11,40 @@ except ImportError:
import appdirs as platformdirs
def get_config_dir() -> Path:
return Path(platformdirs.user_config_dir("dangerzone"))
def get_config_dir() -> str:
return platformdirs.user_config_dir("dangerzone")
def get_resource_path(filename: str) -> Path:
def get_resource_path(filename: str) -> str:
if getattr(sys, "dangerzone_dev", False):
# Look for resources directory relative to python file
project_root = Path(__file__).parent.parent
project_root = pathlib.Path(__file__).parent.parent
prefix = project_root / "share"
else:
if platform.system() == "Darwin":
bin_path = Path(sys.executable)
bin_path = pathlib.Path(sys.executable)
app_path = bin_path.parent.parent
prefix = app_path / "Resources" / "share"
elif platform.system() == "Linux":
prefix = Path(sys.prefix) / "share" / "dangerzone"
prefix = pathlib.Path(sys.prefix) / "share" / "dangerzone"
elif platform.system() == "Windows":
exe_path = Path(sys.executable)
exe_path = pathlib.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
resource_path = prefix / filename
return str(resource_path)
def get_tessdata_dir() -> Path:
def get_tessdata_dir() -> pathlib.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")
return pathlib.Path(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
@ -54,11 +55,11 @@ def get_tessdata_dir() -> Path:
#
# [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
pathlib.Path("/usr/share/tessdata/"), # on some Debian
pathlib.Path("/usr/share/tesseract/tessdata/"), # on Fedora
pathlib.Path("/usr/share/tesseract-ocr/tessdata/"), # ? (documented)
pathlib.Path("/usr/share/tesseract-ocr/4.00/tessdata/"), # on Debian Bullseye
pathlib.Path("/usr/share/tesseract-ocr/5/tessdata/"), # on Debian Trixie
]
for dir in tessdata_dirs:
@ -70,7 +71,7 @@ def get_tessdata_dir() -> Path:
def get_version() -> str:
try:
with get_resource_path("version.txt").open() as f:
with open(get_resource_path("version.txt")) 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

View file

@ -27,7 +27,8 @@ This means that rebuilding the image without updating our Dockerfile will
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_IMAGE_DATE`: The date that the Debian container image was released
* `DEBIAN_IMAGE_DIGEST`: The date that the Debian container image was released
* `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

View file

@ -21,25 +21,34 @@ def get_qt_app() -> Application:
def generate_isolated_updater(
tmp_path: Path,
mocker: MockerFixture,
mock_app: bool = False,
monkeypatch: MonkeyPatch,
app_mocker: Optional[MockerFixture] = None,
) -> UpdaterThread:
"""Generate an Updater class with its own settings."""
app = mocker.MagicMock() if mock_app else get_qt_app()
if app_mocker:
app = app_mocker.MagicMock()
else:
app = get_qt_app()
dummy = Dummy()
mocker.patch("dangerzone.settings.get_config_dir", return_value=tmp_path)
# XXX: We can monkey-patch global state without wrapping it in a context manager, or
# 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)
updater = UpdaterThread(dangerzone)
return updater
@pytest.fixture
def updater(tmp_path: Path, mocker: MockerFixture) -> UpdaterThread:
return generate_isolated_updater(tmp_path, mocker, mock_app=True)
def updater(
tmp_path: Path, monkeypatch: MonkeyPatch, mocker: MockerFixture
) -> UpdaterThread:
return generate_isolated_updater(tmp_path, monkeypatch, mocker)
@pytest.fixture
def qt_updater(tmp_path: Path, mocker: MockerFixture) -> UpdaterThread:
return generate_isolated_updater(tmp_path, mocker, mock_app=False)
def qt_updater(tmp_path: Path, monkeypatch: MonkeyPatch) -> UpdaterThread:
return generate_isolated_updater(tmp_path, monkeypatch)

View file

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

View file

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

View file

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

View file

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