Repository: JonathonReinhart/scuba

Issue:
Detect or handle relative host paths in volumes
Currently a naked word will be interpreted as a named volume.

A relative path will cause docker to complain about the slash.

How should Scuba handle this?

1. Handle this by absolute-ifying the path.
   - Should be interpreted as relative to .scuba.yml
   - Is this necessary? Paths relative (below) .scuba.yml are already accessible via pwd.

2. Detect and complain (abort).

cc @rpasek

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0448a46..5205a4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
 All notable changes to this project will be documented in this file.
 This project adheres to [Semantic Versioning](http://semver.org/).
 
+## [Unreleased]
+### Added
+- Enable the use of relative paths in a volume hostpath (#227)
+
+
 ## [2.11.0] - 2023-09-09
 ### Changed
 - Introduced `pyproject.toml` and moved metadata from `setup.py` (#211)
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 1d7510c..e7782a3 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -145,8 +145,8 @@ style <https://yaml.org/spec/1.2/spec.html#id2788097>`_:
 ``volumes``
 -----------
 
-The optional ``volumes`` node *(added in v2.9.0)* allows additional `volumes
-<https://docs.docker.com/storage/volumes/>`_ or bind-mounts to be specified.
+The optional ``volumes`` node *(added in v2.9.0)* allows additional
+`bind-mounts<https://docs.docker.com/storage/bind-mounts/>`_ to be specified.
 ``volumes`` is a mapping (dictionary) where each key is the container-path.
 In the simple form, the value is a string, the host-path to be bind-mounted:
 
@@ -154,6 +154,7 @@ In the simple form, the value is a string, the host-path to be bind-mounted:
 
     volumes:
       /var/lib/foo: /host/foo
+      /var/lib/bar: ./bar
 
 In the complex form, the value is a mapping which must contain a ``hostpath``
 subkey. It can also contain an ``options`` subkey with a comma-separated list
@@ -179,6 +180,12 @@ directory into the container at the same path.
 If a referenced environment variable is not set, Scuba exits with a
 configuration error.
 
+Volume container paths must be absolute.
+
+Volume host paths can be absolute or relative. If a relative path is used, it
+is interpreted as relative to the directory in which ``.scuba.yml`` is found.
+To avoid ambiguity, relative paths must start with ``./`` or ``../``.
+
 Volume host directories which do not already exist are created as the current
 user before creating the container.
 
diff --git a/scuba/config.py b/scuba/config.py
index 311963a..e6e399c 100644
--- a/scuba/config.py
+++ b/scuba/config.py
@@ -173,7 +173,7 @@ def find_config() -> Tuple[Path, Path, ScubaConfig]:
     while True:
         cfg_path = path / SCUBA_YML
         if cfg_path.exists():
-            return path, Path.cwd().relative_to(path), load_config(cfg_path)
+            return path, Path.cwd().relative_to(path), load_config(cfg_path, path)
 
         if not cross_fs and path.is_mount():
             raise ConfigNotFoundError(
@@ -323,21 +323,49 @@ def _get_delimited_str_list(data: CfgData, key: str, sep: str) -> List[str]:
     return s.split(sep) if s else []
 
 
-def _get_volumes(data: CfgData) -> Optional[Dict[Path, ScubaVolume]]:
+def _get_volumes(
+    data: CfgData, scuba_root: Optional[Path]
+) -> Optional[Dict[Path, ScubaVolume]]:
     voldata = _get_dict(data, "volumes")
     if voldata is None:
         return None
 
     vols = {}
     for cpath_str, v in voldata.items():
-        cpath = _expand_path(cpath_str)
-        vols[cpath] = ScubaVolume.from_dict(cpath, v)
+        cpath = _expand_path(cpath_str)  # no base_dir; container path must be absolute.
+        vols[cpath] = ScubaVolume.from_dict(cpath, v, scuba_root)
     return vols
 
 
-def _expand_path(in_str: str) -> Path:
+def _expand_path(in_str: str, base_dir: Optional[Path] = None) -> Path:
+    """Expand variables in a path string and make it absolute.
+
+    Environment variable references (e.g. $FOO and ${FOO}) are expanded using
+    the host environment.
+
+    After environment variable expansion, absolute paths are returned as-is.
+    Relative paths must start with ./ or ../ and are joined to base_dir, if
+    provided.
+
+    Args:
+      in_str: Input path as a string.
+      base_dir: Path to which relative paths will be joined.
+
+    Returns:
+      An absolute Path.
+
+    Raises:
+      ValueError: If base_dir is provided but not absolute.
+      ConfigError: If a referenced environment variable is not set.
+      ConfigError: An environment variable reference could not be parsed.
+      ConfigError: A relative path does not start with "./" or "../".
+      ConfigError: A relative path is given when base_dir is not provided.
+    """
+    if base_dir is not None and not base_dir.is_absolute():
+        raise ValueError(f"base_dir is not absolute: {base_dir}")
+
     try:
-        output = expand_env_vars(in_str)
+        path_str = expand_env_vars(in_str)
     except KeyError as ke:
         # pylint: disable=raise-missing-from
         raise ConfigError(
@@ -348,7 +376,24 @@ def _expand_path(in_str: str) -> Path:
             f"Unable to expand string '{in_str}' due to parsing errors"
         ) from ve
 
-    return Path(output)
+    path = Path(path_str)
+
+    if not path.is_absolute():
+        if base_dir is None:
+            raise ConfigError(f"Relative path not allowed: {path}")
+
+        # Make sure it starts with ./ or ../
+        # We have to use the original string input since Path() will remove ./
+        valid_prefixes = ("./", "../")
+        if not any(path_str.startswith(pfx) for pfx in valid_prefixes):
+            raise ConfigError(
+                f"Relative path must start with {' or '.join(valid_prefixes)}: {path}"
+            )
+
+        path = base_dir / path
+
+    assert path.is_absolute()
+    return path
 
 
 @dataclasses.dataclass(frozen=True)
@@ -358,7 +403,9 @@ class ScubaVolume:
     options: List[str] = dataclasses.field(default_factory=list)
 
     @classmethod
-    def from_dict(cls, cpath: Path, node: CfgNode) -> ScubaVolume:
+    def from_dict(
+        cls, cpath: Path, node: CfgNode, scuba_root: Optional[Path]
+    ) -> ScubaVolume:
         # Treat a null node as an empty dict
         if node is None:
             node = {}
@@ -369,7 +416,7 @@ class ScubaVolume:
         if isinstance(node, str):
             return cls(
                 container_path=cpath,
-                host_path=_expand_path(node),
+                host_path=_expand_path(node, scuba_root),
             )
 
         # Complex form
@@ -383,7 +430,7 @@ class ScubaVolume:
                 raise ConfigError(f"Volume {cpath} must have a 'hostpath' subkey")
             return cls(
                 container_path=cpath,
-                host_path=_expand_path(hpath),
+                host_path=_expand_path(hpath, scuba_root),
                 options=_get_delimited_str_list(node, "options", ","),
             )
 
@@ -406,7 +453,9 @@ class ScubaAlias:
     volumes: Optional[Dict[Path, ScubaVolume]] = None
 
     @classmethod
-    def from_dict(cls, name: str, node: CfgNode) -> ScubaAlias:
+    def from_dict(
+        cls, name: str, node: CfgNode, scuba_root: Optional[Path]
+    ) -> ScubaAlias:
         script = _process_script_node(node, name)
 
         if isinstance(node, dict):  # Rich alias
@@ -421,7 +470,7 @@ class ScubaAlias:
                 shell=node.get("shell"),
                 as_root=bool(node.get("root")),
                 docker_args=_get_docker_args(node),
-                volumes=_get_volumes(node),
+                volumes=_get_volumes(node, scuba_root),
             )
 
         return cls(name=name, script=script)
@@ -436,7 +485,14 @@ class ScubaConfig:
     hooks: Dict[str, List[str]]
     environment: Environment
 
-    def __init__(self, **data: CfgNode) -> None:
+    def __init__(
+        self,
+        data: Optional[dict[str, CfgNode]] = None,
+        scuba_root: Optional[Path] = None,
+    ) -> None:
+        if data is None:
+            data = {}
+
         optional_nodes = (
             "image",
             "aliases",
@@ -460,17 +516,19 @@ class ScubaConfig:
         self.shell = _get_str(data, "shell", DEFAULT_SHELL)
         self.entrypoint = _get_entrypoint(data)
         self.docker_args = _get_docker_args(data)
-        self.volumes = _get_volumes(data)
-        self.aliases = self._load_aliases(data)
+        self.volumes = _get_volumes(data, scuba_root)
+        self.aliases = self._load_aliases(data, scuba_root)
         self.hooks = self._load_hooks(data)
         self.environment = _process_environment(data.get("environment"), "environment")
 
-    def _load_aliases(self, data: CfgData) -> Dict[str, ScubaAlias]:
+    def _load_aliases(
+        self, data: CfgData, scuba_root: Optional[Path]
+    ) -> Dict[str, ScubaAlias]:
         aliases = {}
         for name, node in data.get("aliases", {}).items():
             if " " in name:
                 raise ConfigError("Alias names cannot contain spaces")
-            aliases[name] = ScubaAlias.from_dict(name, node)
+            aliases[name] = ScubaAlias.from_dict(name, node, scuba_root)
         return aliases
 
     def _load_hooks(self, data: CfgData) -> Dict[str, List[str]]:
@@ -491,7 +549,7 @@ class ScubaConfig:
         return self._image
 
 
-def load_config(path: Path) -> ScubaConfig:
+def load_config(path: Path, scuba_root: Path) -> ScubaConfig:
     try:
         with path.open("r") as f:
             data = yaml.load(f, Loader)
@@ -500,4 +558,4 @@ def load_config(path: Path) -> ScubaConfig:
     except yaml.YAMLError as e:
         raise ConfigError(f"Error loading {SCUBA_YML}: {e}")
 
-    return ScubaConfig(**(data or {}))
+    return ScubaConfig(data, scuba_root)
diff --git a/scuba/dockerutil.py b/scuba/dockerutil.py
index a5207de..a2b1ac4 100644
--- a/scuba/dockerutil.py
+++ b/scuba/dockerutil.py
@@ -137,6 +137,11 @@ def make_vol_opt(
     options: Optional[Sequence[str]] = None,
 ) -> str:
     """Generate a docker volume option"""
+    if not hostdir.is_absolute():
+        raise ValueError(f"hostdir not absolute: {hostdir}")
+    if not contdir.is_absolute():
+        raise ValueError(f"contdir not absolute: {contdir}")
+
     vol = f"--volume={hostdir}:{contdir}"
     if options:
         assert not isinstance(options, str)


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
index 8c1bc06..a982874 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -12,7 +12,7 @@ import scuba.config
 
 
 def load_config() -> scuba.config.ScubaConfig:
-    return scuba.config.load_config(Path(".scuba.yml"))
+    return scuba.config.load_config(Path(".scuba.yml"), Path.cwd())
 
 
 class TestCommonScriptSchema:
@@ -1009,3 +1009,127 @@ class TestConfig:
                 """
             )
         self._invalid_config("TEST_VAR1")
+
+    def test_volumes_hostpath_rel(self, monkeypatch, in_tmp_path) -> None:
+        """volume hostpath can be relative to scuba_root (top dir)"""
+        monkeypatch.setenv("RELVAR", "./spam/eggs")
+
+        with open(".scuba.yml", "w") as f:
+            f.write(
+                r"""
+                image: na
+                volumes:
+                  /bar: ./foo/bar       # simple form, dotted path
+                  /scp:                 # complex form
+                    hostpath: ./snap/crackle/pop
+                  /relvar: $RELVAR      # simple form, relative path in variable
+                """
+            )
+
+        # Make a subdirectory and cd into it
+        subdir = Path("way/down/here")
+        subdir.mkdir(parents=True)
+        monkeypatch.chdir(subdir)
+
+        # Locate the config
+        found_topdir, found_rel, config = scuba.config.find_config()
+        assert found_topdir == in_tmp_path
+        assert found_rel == subdir
+        assert config.volumes is not None
+
+        assert_vol(config.volumes, "/bar", in_tmp_path / "foo" / "bar")
+        assert_vol(config.volumes, "/scp", in_tmp_path / "snap" / "crackle" / "pop")
+        assert_vol(config.volumes, "/relvar", in_tmp_path / "spam" / "eggs")
+
+    def test_volumes_hostpath_rel_above(self, monkeypatch, in_tmp_path) -> None:
+        """volume hostpath can be relative, above scuba_root (top dir)"""
+        # Directory structure:
+        #
+        # test-tmpdir/              # in_tmp_path
+        # |- foo_up_here/           # will be mounted at /foo
+        # |- way/
+        #    |- down/
+        #       |- here/            # scuba roo (found_topdir)
+        #          |- .scuba.yml
+
+        # First, make a subdirectory and cd into it
+        project_dir = Path("way/down/here")
+        project_dir.mkdir(parents=True)
+        monkeypatch.chdir(project_dir)
+
+        # Now put .scuba.yml here
+        with open(".scuba.yml", "w") as f:
+            f.write(
+                r"""
+                image: na
+                volumes:
+                  /foo: ../../../foo_up_here
+                """
+            )
+
+        # Locate the config
+        found_topdir, found_rel, config = scuba.config.find_config()
+        assert found_topdir == in_tmp_path / project_dir
+        assert found_rel == Path()
+
+        assert config.volumes is not None
+        assert_vol(config.volumes, "/foo", in_tmp_path / "foo_up_here")
+
+    def test_volumes_hostpath_rel_req_dot_simple(
+        self, monkeypatch, in_tmp_path
+    ) -> None:
+        """relaitve volume hostpath (simple form) must start with ./ or ../"""
+        with open(".scuba.yml", "w") as f:
+            f.write(
+                r"""
+                image: na
+                volumes:
+                  /one: foo  # Forbidden
+                """
+            )
+        self._invalid_config("Relative path must start with ./ or ../")
+
+    def test_volumes_hostpath_rel_requires_dot_complex(
+        self, monkeypatch, in_tmp_path
+    ) -> None:
+        """relaitve volume hostpath (complex form) must start with ./ or ../"""
+        with open(".scuba.yml", "w") as f:
+            f.write(
+                r"""
+                image: na
+                volumes:
+                  /one:
+                    hostpath: foo  # Forbidden
+                """
+            )
+        self._invalid_config("Relative path must start with ./ or ../")
+
+    def test_volumes_hostpath_rel_in_env(self, monkeypatch, in_tmp_path) -> None:
+        """volume definitions can contain environment variables, including relative path portions"""
+        monkeypatch.setenv("PREFIX", "./")
+        with open(".scuba.yml", "w") as f:
+            f.write(
+                r"""
+                image: na
+                volumes:
+                  /foo: ${PREFIX}/foo
+                """
+            )
+
+        config = load_config()
+        vols = config.volumes
+        assert vols is not None
+        assert len(vols) == 1
+
+        assert_vol(vols, "/foo", in_tmp_path / "foo")
+
+    def test_volumes_contpath_rel(self, monkeypatch, in_tmp_path) -> None:
+        with open(".scuba.yml", "w") as f:
+            f.write(
+                r"""
+                image: na
+                volumes:
+                  foo: /what/now
+                """
+            )
+        self._invalid_config("Relative path not allowed: foo")
diff --git a/tests/test_dockerutil.py b/tests/test_dockerutil.py
index 01c4b12..b0b72a8 100644
--- a/tests/test_dockerutil.py
+++ b/tests/test_dockerutil.py
@@ -125,3 +125,8 @@ def test_make_vol_opt_multi_opts() -> None:
         uut.make_vol_opt(Path("/hostdir"), Path("/contdir"), ["ro", "z"])
         == "--volume=/hostdir:/contdir:ro,z"
     )
+
+
+def test_make_vol_opt__requires_absolute() -> None:
+    with pytest.raises(ValueError):
+        uut.make_vol_opt(Path("hostdir"), Path("/contdir"))
diff --git a/tests/test_main.py b/tests/test_main.py
index 56cf73e..ab28894 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1065,3 +1065,67 @@ class TestMain:
             )
 
         self.run_scuba(["touch", "/userdir/test.txt"], 128)
+
+    def test_volumes_host_path_rel(self) -> None:
+        """Volume host paths can be relative"""
+
+        # Set up a subdir with a file to be read.
+        userdir = Path("./user")
+        userdir.mkdir(parents=True)
+
+        test_message = "Relative paths work"
+        (userdir / "test.txt").write_text(test_message)
+
+        with open(".scuba.yml", "w") as f:
+            f.write(
+                f"""
+                image: {DOCKER_IMAGE}
+                volumes:
+                  /userdir: ./{userdir}
+                """
+            )
+
+        # Invoke scuba from a different subdir, for good measure.
+        otherdir = Path("way/down/here")
+        otherdir.mkdir(parents=True)
+        os.chdir(otherdir)
+
+        out, _ = self.run_scuba(["cat", "/userdir/test.txt"])
+        assert out == test_message
+
+    def test_volumes_hostpath_rel_above(self) -> None:
+        """Volume host paths can be relative, above the scuba root dir"""
+        # Directory structure:
+        #
+        # test-tmpdir/
+        # |- user/                  # will be mounted at /userdir
+        # |  |- test.txt
+        # |- my/
+        #    |- cool/
+        #       |- project/         # scuba root
+        #          |- .scuba.yml
+
+        # Set up a subdir with a file to be read.
+        userdir = Path("./user")
+        userdir.mkdir(parents=True)
+
+        test_message = "Relative paths work"
+        (userdir / "test.txt").write_text(test_message)
+
+        # Set up a subdir for scuba
+        projdir = Path("my/cool/project")
+        projdir.mkdir(parents=True)
+
+        # Change to the project subdir and write the .scuba.yml file there.
+        os.chdir(projdir)
+        with open(".scuba.yml", "w") as f:
+            f.write(
+                f"""
+                image: {DOCKER_IMAGE}
+                volumes:
+                  /userdir: ../../../{userdir}
+                """
+            )
+
+        out, _ = self.run_scuba(["cat", "/userdir/test.txt"])
+        assert out == test_message
diff --git a/tests/test_scuba.py b/tests/test_scuba.py
index 4aff2e6..27984e4 100644
--- a/tests/test_scuba.py
+++ b/tests/test_scuba.py
@@ -1,18 +1,25 @@
 import pytest
 import shlex
+from typing import Any
 from .utils import assert_vol
 
 from scuba.config import ScubaConfig, ConfigError, OverrideStr
 from scuba.scuba import ScubaContext
 
 
+# This exists simply to adapt all of the existing, simple kwargs-style callers
+# to the new one-dict argument ScubaConfig.__init__().
+def make_config(**data: Any) -> ScubaConfig:
+    return ScubaConfig(data)
+
+
 class TestScubaContext:
     def test_process_command_image(self) -> None:
         """process_command returns the image and entrypoint"""
         image_name = "test_image"
         entrypoint = "test_entrypoint"
 
-        cfg = ScubaConfig(
+        cfg = make_config(
             image=image_name,
             entrypoint=entrypoint,
         )
@@ -22,7 +29,7 @@ class TestScubaContext:
 
     def test_process_command_empty(self) -> None:
         """process_command handles no aliases and an empty command"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="na",
         )
         result = ScubaContext.process_command(cfg, [])
@@ -30,7 +37,7 @@ class TestScubaContext:
 
     def test_process_command_no_aliases(self) -> None:
         """process_command handles no aliases"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="na",
         )
         result = ScubaContext.process_command(cfg, ["cmd", "arg1", "arg2"])
@@ -41,7 +48,7 @@ class TestScubaContext:
 
     def test_process_command_aliases_unused(self) -> None:
         """process_command handles unused aliases"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="na",
             aliases=dict(
                 apple="banana",
@@ -56,7 +63,7 @@ class TestScubaContext:
 
     def test_process_command_aliases_used_noargs(self) -> None:
         """process_command handles aliases with no args"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="na",
             aliases=dict(
                 apple="banana",
@@ -71,7 +78,7 @@ class TestScubaContext:
 
     def test_process_command_aliases_used_withargs(self) -> None:
         """process_command handles aliases with args"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="na",
             aliases=dict(
                 apple='banana cherry "pie is good"',
@@ -88,7 +95,7 @@ class TestScubaContext:
 
     def test_process_command_multiline_aliases_used(self) -> None:
         """process_command handles multiline aliases"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="na",
             aliases=dict(
                 apple=dict(
@@ -109,7 +116,7 @@ class TestScubaContext:
 
     def test_process_command_multiline_aliases_forbid_user_args(self) -> None:
         """process_command raises ConfigError when args are specified with multiline aliases"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="na",
             aliases=dict(
                 apple=dict(
@@ -126,7 +133,7 @@ class TestScubaContext:
 
     def test_process_command_alias_overrides_image(self) -> None:
         """aliases can override the image"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="default",
             aliases=dict(
                 apple=dict(
@@ -143,7 +150,7 @@ class TestScubaContext:
 
     def test_process_command_alias_overrides_image_and_entrypoint(self) -> None:
         """aliases can override the image and entrypoint"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="default",
             entrypoint="default_entrypoint",
             aliases=dict(
@@ -163,7 +170,7 @@ class TestScubaContext:
 
     def test_process_command_alias_overrides_image_and_empty_entrypoint(self) -> None:
         """aliases can override the image and empty/null entrypoint"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="default",
             entrypoint="default_entrypoint",
             aliases=dict(
@@ -185,7 +192,7 @@ class TestScubaContext:
         """process_command allows image to be overridden when provided"""
         override_image_name = "override_image"
 
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="test_image",
         )
         result = ScubaContext.process_command(
@@ -197,7 +204,7 @@ class TestScubaContext:
         """process_command allows image to be overridden when not provided"""
         override_image_name = "override_image"
 
-        cfg = ScubaConfig()
+        cfg = make_config()
         result = ScubaContext.process_command(
             cfg, [], image_override=override_image_name
         )
@@ -207,7 +214,7 @@ class TestScubaContext:
         """process_command allows image to be overridden when provided by alias"""
         override_image_name = "override_image"
 
-        cfg = ScubaConfig(
+        cfg = make_config(
             aliases=dict(
                 apple=dict(
                     script=[
@@ -225,7 +232,7 @@ class TestScubaContext:
 
     def test_env_merge(self) -> None:
         """process_command merges/overrides the environment from the alias"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="dontcare",
             environment=dict(
                 AAA="aaa_base",
@@ -251,7 +258,7 @@ class TestScubaContext:
 
     def test_process_command_alias_extends_docker_args(self) -> None:
         """aliases can extend the docker_args"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="default",
             docker_args="--privileged",
             aliases=dict(
@@ -268,7 +275,7 @@ class TestScubaContext:
 
     def test_process_command_alias_overrides_docker_args(self) -> None:
         """aliases can override the docker_args"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="default",
             docker_args="--privileged",
             aliases=dict(
@@ -285,7 +292,7 @@ class TestScubaContext:
 
     def test_process_command_alias_overrides_docker_args_with_empty(self) -> None:
         """aliases can override the docker_args"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="default",
             docker_args="--privileged",
             aliases=dict(
@@ -302,7 +309,7 @@ class TestScubaContext:
 
     def test_process_command_alias_inherits_top_docker_args(self) -> None:
         """aliases inherit the top-level docker_args if not specified"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="default",
             docker_args="--privileged",
             aliases=dict(
@@ -321,7 +328,7 @@ class TestScubaContext:
 
     def test_process_command_alias_extends_volumes(self) -> None:
         """aliases can extend the volumes"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="default",
             volumes={
                 "/foo": "/host/foo",
@@ -346,7 +353,7 @@ class TestScubaContext:
 
     def test_process_command_alias_updates_volumes(self) -> None:
         """aliases can extend the volumes"""
-        cfg = ScubaConfig(
+        cfg = make_config(
             image="default",
             volumes={
                 "/foo": "/host/foo",
diff --git a/tests/utils.py b/tests/utils.py
index ba329e5..efe7036 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -19,6 +19,15 @@ def assert_seq_equal(a: Sequence, b: Sequence) -> None:
 
 
 def assert_paths_equal(a: PathStr, b: PathStr) -> None:
+    # NOTE: normpath() can behave undesirably in the face of symlinks, so this
+    # comparison is probably not perfect. But since we're often dealing with
+    # "pure" paths (that don't actually exist on the filesystem), there's not
+    # much more we can do.
+    #
+    # "This string manipulation may change the meaning of a path that contains
+    # symbolic links."
+    #
+    # https://docs.python.org/3/library/os.path.html#os.path.normpath
     assert normpath(a) == normpath(b)
 
 
@@ -38,8 +47,8 @@ def assert_vol(
     hpath = Path(hpath_str)
     v = vols[cpath]
     assert isinstance(v, ScubaVolume)
-    assert v.container_path == cpath
-    assert v.host_path == hpath
+    assert_paths_equal(v.container_path, cpath)
+    assert_paths_equal(v.host_path, hpath)
     assert v.options == options