← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~lgp171188/lpcraft:contain-config-file-path-under-project-dir into lpcraft:main

 

Guruprasad has proposed merging ~lgp171188/lpcraft:contain-config-file-path-under-project-dir into lpcraft:main.

Commit message:
Require the configuration file to be under the project directory

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~lgp171188/lpcraft/+git/lpcraft/+merge/418035
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~lgp171188/lpcraft:contain-config-file-path-under-project-dir into lpcraft:main.
diff --git a/NEWS.rst b/NEWS.rst
index f4d3db9..769a6b1 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -12,6 +12,8 @@ Version history
 
 - pre-commit: Add the ``pydocstyle`` hook to lint the docstrings.
 
+- Require the configuration file to be present under the project directory.
+
 0.0.5   (2022-03-30)
 ====================
 
diff --git a/lpcraft/commands/clean.py b/lpcraft/commands/clean.py
index 5d67579..7464df1 100644
--- a/lpcraft/commands/clean.py
+++ b/lpcraft/commands/clean.py
@@ -17,18 +17,20 @@ def clean(args: Namespace) -> int:
     config_path = getattr(args, "config", Path(".launchpad.yaml"))
     Config.load(config_path)
 
-    cwd = Path.cwd()
+    project_dir = Path.cwd()
     emit.progress(
-        f"Deleting the managed environments for the {cwd.name!r} project."
+        "Deleting the managed environments for the "
+        f"{project_dir.name!r} project."
     )
 
     provider = get_provider()
     provider.ensure_provider_is_available()
 
     provider.clean_project_environments(
-        project_name=cwd.name, project_path=cwd
+        project_name=project_dir.name, project_path=project_dir
     )
     emit.message(
-        f"Deleted the managed environments for the {cwd.name!r} project."
+        "Deleted the managed environments for the "
+        f"{project_dir.name!r} project."
     )
     return 0
diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
index 23e9a04..389517e 100644
--- a/lpcraft/commands/run.py
+++ b/lpcraft/commands/run.py
@@ -21,23 +21,7 @@ from lpcraft.config import Config, Job, Output
 from lpcraft.errors import CommandError
 from lpcraft.plugin.manager import get_plugin_manager
 from lpcraft.providers import Provider, get_provider
-from lpcraft.utils import get_host_architecture
-
-
-def _check_relative_path(path: PurePath, container: PurePath) -> PurePath:
-    """Check that `path` does not escape `container`.
-
-    Any symlinks in `path` must already have been resolved within the
-    context of the container.
-
-    :raises CommandError: if `path` is outside `container` when fully
-        resolved.
-    :return: A version of `path` relative to `container`.
-    """
-    try:
-        return path.relative_to(container)
-    except ValueError as e:
-        raise CommandError(str(e), retcode=1)
+from lpcraft.utils import check_relative_path, get_host_architecture
 
 
 def _list_files(instance: Executor, path: Path) -> List[PurePath]:
@@ -97,7 +81,7 @@ def _copy_output_paths(
         # pattern as a whole first produces clearer error messages.  We have
         # to use os.path for this, as pathlib doesn't expose any equivalent
         # of normpath.
-        _check_relative_path(
+        check_relative_path(
             PurePath(os.path.normpath(remote_cwd / path_pattern)),
             remote_cwd,
         )
@@ -122,7 +106,7 @@ def _copy_output_paths(
     )
 
     for path in sorted(resolved_paths):
-        relative_path = _check_relative_path(path, remote_cwd)
+        relative_path = check_relative_path(path, remote_cwd)
         destination = output_files / relative_path
         destination.parent.mkdir(parents=True, exist_ok=True)
         try:
@@ -144,7 +128,7 @@ def _copy_output_properties(
             instance,
             [remote_cwd / output.dynamic_properties],
         )
-        _check_relative_path(path, remote_cwd)
+        check_relative_path(path, remote_cwd)
         dynamic_properties = dotenv_values(
             stream=io.StringIO(
                 instance.execute_run(
@@ -346,7 +330,8 @@ def run_one(args: Namespace) -> int:
 
     (This command is for use by Launchpad, and is subject to change.)
     """
-    config = Config.load(args.config)
+    config_path = getattr(args, "config", Path(".launchpad.yaml"))
+    config = Config.load(config_path)
 
     jobs = config.jobs.get(args.job, [])
     if not jobs:
diff --git a/lpcraft/commands/tests/test_clean.py b/lpcraft/commands/tests/test_clean.py
index 5a11555..150676a 100644
--- a/lpcraft/commands/tests/test_clean.py
+++ b/lpcraft/commands/tests/test_clean.py
@@ -24,6 +24,31 @@ class TestClean(CommandBaseTestCase):
         os.chdir(self.tmp_project_path)
         self.addCleanup(os.chdir, cwd)
 
+    def test_config_file_not_under_project_directory(self):
+        paths = [
+            "/",
+            "/etc/init.d",
+            "../../foo",
+            "a/b/c/../../../../d",
+        ]
+
+        for path in paths:
+            config_file = f"{path}/config.yaml"
+            result = self.run_command(
+                "clean",
+                "-c",
+                config_file,
+            )
+            config_file_path = Path(config_file).resolve()
+            # The exception message differs between Python 3.8 and 3.9+.
+            # That is why the `result` object is manually tested below.
+            self.assertEqual(1, result.exit_code)
+            self.assertEqual(1, len(result.errors))
+            self.assertTrue(isinstance(result.errors[0], CommandError))
+            message = result.errors[0].args[0]
+            self.assertIn(str(config_file_path), message)
+            self.assertIn(str(self.tmp_project_path), message)
+
     def test_missing_config_file(self):
         result = self.run_command("clean")
 
diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py
index cda2aaa..f9e5670 100644
--- a/lpcraft/commands/tests/test_run.py
+++ b/lpcraft/commands/tests/test_run.py
@@ -40,7 +40,7 @@ class LocalExecuteRun:
         *,
         cwd: Optional[Path] = None,
         env: Optional[Dict[str, Optional[str]]] = None,
-        **kwargs: Any
+        **kwargs: Any,
     ) -> "subprocess.CompletedProcess[AnyStr]":
         run_kwargs = kwargs.copy()
         run_kwargs["cwd"] = self.override_cwd
@@ -83,6 +83,32 @@ class RunBaseTestCase(CommandBaseTestCase):
 
 
 class TestRun(RunBaseTestCase):
+    def test_config_file_not_under_project_directory(self):
+        paths = [
+            "/",
+            "/etc/init.d",
+            "../../foo",
+            "a/b/c/../../../../d",
+        ]
+
+        for path in paths:
+            config_file = f"{path}/config.yaml"
+            result = self.run_command(
+                "run",
+                "-c",
+                config_file,
+            )
+            config_file_path = Path(config_file).resolve()
+            config_file_path = Path(config_file).resolve()
+            # The exception message differs between Python 3.8 and 3.9+.
+            # That is why the `result` object is manually tested below.
+            self.assertEqual(1, result.exit_code)
+            self.assertEqual(1, len(result.errors))
+            self.assertTrue(isinstance(result.errors[0], CommandError))
+            message = result.errors[0].args[0]
+            self.assertIn(str(config_file_path), message)
+            self.assertIn(str(self.tmp_project_path), message)
+
     def test_missing_config_file(self):
         result = self.run_command("run")
 
@@ -1630,6 +1656,33 @@ class TestRun(RunBaseTestCase):
 
 
 class TestRunOne(RunBaseTestCase):
+    def test_config_file_not_under_project_directory(self):
+        paths = [
+            "/",
+            "/etc/init.d",
+            "../../foo",
+            "a/b/c/../../../../d",
+        ]
+
+        for path in paths:
+            config_file = f"{path}/config.yaml"
+            result = self.run_command(
+                "run-one",
+                "-c",
+                config_file,
+                "test",
+                "0",
+            )
+            config_file_path = Path(config_file).resolve()
+            # The exception message differs between Python 3.8 and 3.9+.
+            # That is why the `result` object is manually tested below.
+            self.assertEqual(1, result.exit_code)
+            self.assertEqual(1, len(result.errors))
+            self.assertTrue(isinstance(result.errors[0], CommandError))
+            message = result.errors[0].args[0]
+            self.assertIn(str(config_file_path), message)
+            self.assertIn(str(self.tmp_project_path), message)
+
     def test_missing_config_file(self):
         result = self.run_command("run-one", "test", "0")
 
diff --git a/lpcraft/config.py b/lpcraft/config.py
index 0777dd3..6b9286b 100644
--- a/lpcraft/config.py
+++ b/lpcraft/config.py
@@ -10,7 +10,7 @@ from typing import Any, Dict, List, Optional, Union
 import pydantic
 from pydantic import StrictStr
 
-from lpcraft.utils import load_yaml
+from lpcraft.utils import check_relative_path, load_yaml
 
 
 class _Identifier(pydantic.ConstrainedStr):
@@ -127,5 +127,11 @@ class Config(ModelConfigDefaults):
     @classmethod
     def load(cls, path: Path) -> "Config":
         """Load config from the indicated file name."""
+        # XXX lgp171188 2022-03-31: it is not a good idea to evaluate
+        # `Path.cwd()` in multiple places to determine the project directory.
+        # This could be made available as an attribute on the `Config` class
+        # instead.
+        project_dir = Path.cwd()
+        check_relative_path(path.resolve(), project_dir)
         content = load_yaml(path)
         return cls.parse_obj(content)
diff --git a/lpcraft/tests/test_config.py b/lpcraft/tests/test_config.py
index 96025b6..6867024 100644
--- a/lpcraft/tests/test_config.py
+++ b/lpcraft/tests/test_config.py
@@ -1,6 +1,7 @@
 # Copyright 2021-2022 Canonical Ltd.  This software is licensed under the
 # GNU General Public License version 3 (see the file LICENSE).
 
+import os
 from datetime import timedelta
 from pathlib import Path
 from textwrap import dedent
@@ -16,18 +17,37 @@ from testtools.matchers import (
 )
 
 from lpcraft.config import Config, OutputDistributeEnum
+from lpcraft.errors import CommandError
 
 
 class TestConfig(TestCase):
     def setUp(self):
         super().setUp()
         self.tempdir = Path(self.useFixture(TempDir()).path)
+        # `Path.cwd()` is assumed as the project directory.
+        # So switch to the created project directory.
+        os.chdir(self.tempdir)
 
     def create_config(self, text):
         path = self.tempdir / ".launchpad.yaml"
         path.write_text(text)
         return path
 
+    def test_load_config_not_under_project_dir(self):
+        paths_outside_project_dir = [
+            "/",
+            "/etc/init.d",
+            "../../foo",
+            "a/b/c/../../../../d",
+        ]
+        for path in paths_outside_project_dir:
+            config_file = Path(path) / "config.yaml"
+            self.assertRaises(
+                CommandError,
+                Config.load,
+                config_file,
+            )
+
     def test_load(self):
         path = self.create_config(
             dedent(
diff --git a/lpcraft/tests/test_utils.py b/lpcraft/tests/test_utils.py
index 52b42e3..71ed1b2 100644
--- a/lpcraft/tests/test_utils.py
+++ b/lpcraft/tests/test_utils.py
@@ -10,8 +10,13 @@ from fixtures import TempDir
 from systemfixtures import FakeProcesses
 from testtools import TestCase
 
-from lpcraft.errors import ConfigurationError
-from lpcraft.utils import ask_user, get_host_architecture, load_yaml
+from lpcraft.errors import CommandError, ConfigurationError
+from lpcraft.utils import (
+    ask_user,
+    check_relative_path,
+    get_host_architecture,
+    load_yaml,
+)
 
 
 class TestLoadYAML(TestCase):
@@ -145,3 +150,21 @@ class TestAskUser(TestCase):
 
                 mock_input.assert_called_once_with("prompt [y/N]: ")
                 mock_input.reset_mock()
+
+
+class TestCheckRelativePath(TestCase):
+    def test_relative_path_uncomputable(self):
+        paths = [
+            "/",
+            "/etc/init.d" "../../foo",
+            "a/b/c/../../../../d",
+        ]
+        container = Path(self.useFixture(TempDir()).path)
+
+        for path in paths:
+            self.assertRaises(
+                CommandError,
+                check_relative_path,
+                Path(path),
+                container,
+            )
diff --git a/lpcraft/utils.py b/lpcraft/utils.py
index a369cbd..2e907e1 100644
--- a/lpcraft/utils.py
+++ b/lpcraft/utils.py
@@ -1,4 +1,4 @@
-# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# Copyright 2021-22 Canonical Ltd.  This software is licensed under the
 # GNU General Public License version 3 (see the file LICENSE).
 
 __all__ = [
@@ -10,12 +10,12 @@ __all__ = [
 import subprocess
 import sys
 from functools import lru_cache
-from pathlib import Path
+from pathlib import Path, PurePath
 from typing import Any, Dict
 
 import yaml
 
-from lpcraft.errors import ConfigurationError
+from lpcraft.errors import CommandError, ConfigurationError
 
 
 def load_yaml(path: Path) -> Dict[Any, Any]:
@@ -69,3 +69,19 @@ def ask_user(prompt: str, default: bool = False) -> bool:
         elif reply[0] == "n":
             return False
     return default
+
+
+def check_relative_path(path: PurePath, container: PurePath) -> PurePath:
+    """Check that `path` does not escape `container`.
+
+    Any symlinks in `path` must already have been resolved within the
+    context of the container.
+
+    :raises CommandError: if `path` is outside `container` when fully
+        resolved.
+    :return: A version of `path` relative to `container`.
+    """
+    try:
+        return path.relative_to(container)
+    except ValueError as e:
+        raise CommandError(str(e))

Follow ups