diff --git a/Makefile b/Makefile index 5f927f33..983f0ddb 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ checks: typecheck lint test coverage: test coverage-report -test: test-cli test-python-executable test-pip test-pip-class test-poetry test-poetry-class test-npm test-npm-class test-verifiers +test: test-cli test-python-executable test-pip test-pip-class test-poetry test-poetry-class test-npm test-npm-class test-go test-go-class test-verifiers typecheck: mypy --install-types --non-interactive scfw @@ -41,6 +41,12 @@ test-npm: test-npm-class: COVERAGE_FILE=.coverage.npm.class coverage run -m pytest tests/package_managers/test_npm_class.py +test-go: + COVERAGE_FILE=.coverage.go coverage run -m pytest tests/package_managers/test_go.py + +test-go-class: + COVERAGE_FILE=.coverage.go.class coverage run -m pytest tests/package_managers/test_go_class.py + test-verifiers: COVERAGE_FILE=.coverage.verifiers coverage run -m pytest tests/verifiers @@ -49,6 +55,7 @@ coverage-report: .coverage.python.executable .coverage.pip .coverage.pip.class \ .coverage.poetry .coverage.poetry.class \ .coverage.npm .coverage.npm.class \ + .coverage.go .coverage.go.class \ .coverage.verifiers coverage report diff --git a/README.md b/README.md index 28ef9e44..9b52d198 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,12 @@ $ scfw configure ### Compatibility and limitations -| Package manager | Compatible versions | Inspected subcommands | -| :---------------: | :-------------------: | :--------------------------------: | -| npm | >= 7.0 | `install` (including aliases) | -| pip | >= 22.2 | `install` | -| poetry | >= 1.7 | `add`, `install`, `sync`, `update` | +| Package manager | Compatible versions | Inspected subcommands | +| :---------------: | :-------------------: | :-------------------------------------------------: | +| npm | >= 7.0 | `install` (including aliases) | +| pip | >= 22.2 | `install` | +| poetry | >= 1.7 | `add`, `install`, `sync`, `update` | +| go | >= 1.17.0 | `build`, `generate`, `get`, `install`, `mod`, `run` | In keeping with its goal of blocking 100% of known-malicious package installations, `scfw` will refuse to run with an incompatible version of a supported package manager. Please upgrade to or verify that you are running a compatible version before using this tool. @@ -89,6 +90,7 @@ To use Supply-Chain Firewall to inspect a package manager command, simply prepen $ scfw run npm install react $ scfw run pip install -r requirements.txt $ scfw run poetry add git+https://github.com/DataDog/guarddog +$ scfw run go mod download ``` For `pip install` commands, packages will be installed in the same environment (virtual or global) in which the command was run. diff --git a/scfw/cli.py b/scfw/cli.py index 10a6472d..79bf0935 100644 --- a/scfw/cli.py +++ b/scfw/cli.py @@ -77,6 +77,12 @@ def _add_configure_cli(parser: ArgumentParser): help="Add shell aliases to always run Poetry commands through Supply-Chain Firewall" ) + parser.add_argument( + "--alias-go", + action="store_true", + help="Add shell aliases to always run go commands through Supply-Chain Firewall" + ) + parser.add_argument( "--dd-agent-port", type=str, @@ -306,6 +312,7 @@ def _parse_command_line(argv: list[str]) -> tuple[Optional[Namespace], str]: args.alias_npm, args.alias_pip, args.alias_poetry, + args.alias_go, args.dd_agent_port, args.dd_api_key, args.dd_log_level, diff --git a/scfw/configure/__init__.py b/scfw/configure/__init__.py index aab6d725..fb48622e 100644 --- a/scfw/configure/__init__.py +++ b/scfw/configure/__init__.py @@ -31,6 +31,7 @@ def run_configure(args: Namespace) -> int: "alias_npm": False, "alias_pip": False, "alias_poetry": False, + "alias_go": False, "dd_agent_port": None, "dd_api_key": None, "dd_log_level": None, @@ -50,6 +51,7 @@ def run_configure(args: Namespace) -> int: args.alias_npm, args.alias_pip, args.alias_poetry, + args.alias_go, args.dd_agent_port, args.dd_api_key, args.dd_log_level, diff --git a/scfw/configure/env.py b/scfw/configure/env.py index 5e4610f1..20203c98 100644 --- a/scfw/configure/env.py +++ b/scfw/configure/env.py @@ -77,6 +77,8 @@ def _format_answers(answers: dict) -> str: config += '\nalias pip="scfw run pip"' if answers.get("alias_poetry"): config += '\nalias poetry="scfw run poetry"' + if answers.get("alias_go"): + config += '\nalias go="scfw run go"' if (dd_agent_port := answers.get("dd_agent_port")): config += f'\nexport {DD_AGENT_PORT_VAR}="{dd_agent_port}"' if (dd_api_key := answers.get("dd_api_key")): diff --git a/scfw/configure/interactive.py b/scfw/configure/interactive.py index 05f2515b..76e939d8 100644 --- a/scfw/configure/interactive.py +++ b/scfw/configure/interactive.py @@ -57,6 +57,11 @@ def get_answers() -> dict: message="Would you like to set a shell alias to run all Poetry commands through the firewall?", default=True ), + inquirer.Confirm( + name="alias_go", + message="Would you like to set a shell alias to run all go commands through the firewall?", + default=True + ), inquirer.Confirm( name="dd_agent_logging", message="If you have the Datadog Agent installed locally, would you like to forward firewall logs to it?", diff --git a/scfw/ecosystem.py b/scfw/ecosystem.py index 5a1fbf55..117aed80 100644 --- a/scfw/ecosystem.py +++ b/scfw/ecosystem.py @@ -11,6 +11,7 @@ class ECOSYSTEM(Enum): """ Npm = "npm" PyPI = "PyPI" + Go = "Go" def __str__(self) -> str: """ diff --git a/scfw/package.py b/scfw/package.py index aec55cfb..a3e61a27 100644 --- a/scfw/package.py +++ b/scfw/package.py @@ -28,11 +28,12 @@ def __str__(self) -> str: Returns: A `str` with ecosystem-specific formatting describing the `Package` name and version. + `go` packages: `"{name}@{version}"`. `npm` packages: `"{name}@{version}"`. `PyPI` packages: `"{name}-{version}"` """ match self.ecosystem: - case ECOSYSTEM.Npm: + case ECOSYSTEM.Npm | ECOSYSTEM.Go: return f"{self.name}@{self.version}" case ECOSYSTEM.PyPI: return f"{self.name}-{self.version}" diff --git a/scfw/package_managers/__init__.py b/scfw/package_managers/__init__.py index 18b033d1..85562ca7 100644 --- a/scfw/package_managers/__init__.py +++ b/scfw/package_managers/__init__.py @@ -6,11 +6,13 @@ from typing import Optional from scfw.package_manager import PackageManager +from scfw.package_managers.go import Go from scfw.package_managers.npm import Npm from scfw.package_managers.pip import Pip from scfw.package_managers.poetry import Poetry SUPPORTED_PACKAGE_MANAGERS = [ + Go.name(), Npm.name(), Pip.name(), Poetry.name(), @@ -37,6 +39,8 @@ def get_package_manager(name: str, executable: Optional[str] = None) -> PackageM if not name: raise ValueError("Missing package manager") + if name == Go.name(): + return Go(executable) if name == Npm.name(): return Npm(executable) if name == Pip.name(): diff --git a/scfw/package_managers/go.py b/scfw/package_managers/go.py new file mode 100644 index 00000000..0c206075 --- /dev/null +++ b/scfw/package_managers/go.py @@ -0,0 +1,373 @@ +""" +Defines a subclass of `PackageManagerCommand` for `go` commands. +""" + +import logging +import os +import pathlib +import platform +import re +import shutil +import subprocess +import tempfile +from types import TracebackType +from typing import Optional, Type, TypeVar + +from packaging.version import InvalidVersion, Version, parse as version_parse + +from scfw.ecosystem import ECOSYSTEM +from scfw.package import Package +from scfw.package_manager import PackageManager, UnsupportedVersionError + +_log = logging.getLogger(__name__) + +MIN_GO_VERSION = version_parse("1.17.0") + +INSPECTED_SUBCOMMANDS = {"build", "generate", "get", "install", "mod", "run"} + +INSPECTED_MOD_COMMANDS = {"download", "graph", "tidy", "verify", "why"} + +DRY_RUN_PROJECT = "localhost/dry_run" + + +class Go(PackageManager): + """ + A `PackageManager` representation of `go`. + """ + def __init__(self, executable: Optional[str] = None): + """ + Initialize a new `Go` instance. + + Args: + executable: + An optional path in the local filesystem to the `go` executable to use. + If not provided, this value is determined by the current environment. + + Raises: + RuntimeError: A valid executable could not be resolved. + UnsupportedVersionError: The underlying `go` executable is of an unsupported version. + """ + def get_go_version(executable: str) -> Version: + try: + # All supported versions adhere to this format + go_version = subprocess.run([executable, "version"], check=True, text=True, capture_output=True) + if (match := re.search(r".*go(\d*(?:\.\d+)*).*", go_version.stdout.strip())): + return version_parse(match.group(1)) + raise UnsupportedVersionError("Failed to parse Go version output") + except InvalidVersion: + raise UnsupportedVersionError("Failed to parse Go version number") + + executable = executable if executable else shutil.which(self.name()) + if not executable: + raise RuntimeError("Failed to resolve local Go executable") + if not os.path.isfile(executable): + raise RuntimeError(f"Path '{executable}' does not correspond to a regular file") + + if get_go_version(executable) < MIN_GO_VERSION: + raise UnsupportedVersionError(f"Go before v{MIN_GO_VERSION} is not supported") + + self._executable = executable + + @classmethod + def name(cls) -> str: + """ + Return the token for invoking `go` on the command line. + """ + return "go" + + @classmethod + def ecosystem(cls) -> ECOSYSTEM: + """ + Return the package ecosystem of `go` commands. + """ + return ECOSYSTEM.Go + + def executable(self) -> str: + """ + Query the executable for a `go` command. + """ + return self._executable + + def run_command(self, command: list[str]): + """ + Run a `go` command. + + Args: + command: A `list[str]` containing a `go` command to execute. + + Raises: + ValueError: The given `command` is empty or not a valid `go` command. + """ + subprocess.run(self._normalize_command(command)) + + def resolve_install_targets(self, command: list[str]) -> list[Package]: + """ + Resolve the installation targets of the given `go` command. + + Args: + command: + A `list[str]` representing a `go` command whose installation targets + are to be resolved. + + Returns: + A `list[Package]` representing the package targets that would be installed + if `command` were run. + + Raises: + ValueError: The given `command` is empty or not a valid `go` command. + GoModNotFoundError: No `go.mod` file was found for the given `go` command. + """ + _TempGoEnvironmentType = TypeVar('_TempGoEnvironmentType', bound='TempGoEnvironment') + + class TempGoEnvironment(tempfile.TemporaryDirectory): + """ + Prepares a temporary environment in which go commands may be executed + without affecting the user's global environment. + + This may be used as a context manager. On completion of the context + the temporary environment will be removed from the filesystem, alongside + any changes that may have been made to the current project. + + Alternatively, when done with the environment, you may call cleanup() + to remove the temporary environment. + """ + def __init__(self, executable: str): + """ + Initialize a new `TempGoEnvironment`. + + Args: + executable: Path to the `go` binary. + """ + tempfile.TemporaryDirectory.__init__(self) + + self._executable = executable + self._restore_mod_file = False + self._restore_sum_file = False + self._remove_sum_file = False + self._original_dir = None + + gomod_command = [self._executable, "env", "GOMOD"] + gomod = subprocess.run(gomod_command, check=True, text=True, capture_output=True) + gomod_path = gomod.stdout.strip() + if gomod_path != "/dev/null" and gomod_path != "NUL": + self._original_dir = pathlib.Path(gomod_path).absolute().parent + + self._create_tmp_env() + + def _create_tmp_env(self): + """ + Create the temporary environment and set every environment variable + required to run `go` commands keeping the global environment clean. + """ + self.tmp_dir = pathlib.Path(self.name) + + go_dir = self.tmp_dir / "go" + go_dir.mkdir(mode=0o750) + + self._dry_run_dir = self.tmp_dir / "dry_run" + self._dry_run_dir.mkdir(mode=0o750) + + cache_dir = self.tmp_dir / "cache" + cache_dir.mkdir(mode=0o750) + + mod_cache_dir = self.tmp_dir / "mod_cache" + mod_cache_dir.mkdir(mode=0o750) + + # Go searches each directory listed in GOPATH to find source code, + # but new packages are always downloaded into the first directory + # in the list. + gopath_command = [self._executable, "env", "GOPATH"] + gopath = subprocess.run(gopath_command, check=True, text=True, capture_output=True) + + separator = ":" + if platform.system() == "Windows": + separator = ";" + + self.env = os.environ.copy() + self.env['GOPATH'] = f"{go_dir}{separator}{gopath.stdout.strip()}" + self.env['GOCACHE'] = str(cache_dir) + self.env['GOMODCACHE'] = str(mod_cache_dir) + + def __enter__(self: _TempGoEnvironmentType) -> _TempGoEnvironmentType: + """ + Convert the `TempGoEnvironment` to a context manager. + + Returns: + The object itself managing the temporary environment. + """ + return self + + def cleanup(self): + """ + Clear the temporary environment and undo any changes made to the current project. + """ + if self._original_dir is not None: + if self._restore_mod_file: + mod_file = self._original_dir / "go.mod" + tmp_file = self.tmp_dir / "go.mod" + shutil.copy(tmp_file, mod_file) + + sum_file = self._original_dir / "go.sum" + if self._restore_sum_file: + tmp_file = self.tmp_dir / "go.sum" + shutil.copy(tmp_file, sum_file) + elif self._remove_sum_file: + os.remove(sum_file) + + tempfile.TemporaryDirectory.cleanup(self) + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ): + """ + Clear the temporary environment and undo any changes made to the current project. + """ + self.cleanup() + + def run(self, args: list[str], local: bool = False) -> subprocess.CompletedProcess: + """ + Execute a go command within the temporary environment. + + Args: + args: The list of arguments passed to `go`. + Returns: + A representation of the finished proccess. + """ + cwd = self._dry_run_dir + if local: + cwd = None + + command = [self._executable] + args + return subprocess.run(command, cwd=cwd, env=self.env, check=True, text=True, capture_output=True) + + def duplicate_go_mod(self): + """ + Duplicate the go.mod and go.sum in the nearest ancestor directory. + + On clean up, these files are recovered, in case they were modified. + """ + if self._original_dir is None: + raise GoModNotFoundError("Failed to find a 'go.mod' file to operate on") + + mod_file = self._original_dir / "go.mod" + shutil.copy(mod_file, self.tmp_dir) + self._restore_mod_file = True + + sum_file = self._original_dir / "go.sum" + if sum_file.exists(): + shutil.copy(sum_file, self.tmp_dir) + self._restore_sum_file = True + else: + self._remove_sum_file = True + + def line_to_package(line: str) -> Optional[Package]: + # All supported versions adhere to this format + components = line.strip().split() + if len(components) == 2 and components[0] != DRY_RUN_PROJECT: + return Package(self.ecosystem(), components[0], components[1]) + return None + + command = self._normalize_command(command) + + if not any(subcommand in command for subcommand in INSPECTED_SUBCOMMANDS): + return [] + + if len(command) > 2 and command[1] == "mod" and not command[2] in INSPECTED_MOD_COMMANDS: + return [] + + # The presence of these options prevent the add command from running + if any(opt in command for opt in {"-h", "-help"}): + return [] + + try: + # Compute installation targets: new dependencies and updates/downgrades of existing ones + + local_packages = [] + remote_packages = [] + + is_tidy = len(command) > 2 and command[1] == "mod" and command[2] == "tidy" + for param in command[2 if not is_tidy else 3:]: + if not param.startswith("-"): + # TODO: Handle packages with envvars? + is_local = pathlib.Path(param).exists() + if is_local or len(param.split("/")) == 1: + local_packages.append(param) + else: + remote_packages.append(param) + + with TempGoEnvironment(self.executable()) as tmp: + packages = set() + if len(remote_packages) > 0: + # Create a temporary project and retrieve what would be installed in it. + tmp.run(["mod", "init", DRY_RUN_PROJECT]) + tmp.run(["get"] + remote_packages) + dry_run = tmp.run(["list", "-m", "all"]) + packages.update(set(filter(None, map(line_to_package, dry_run.stdout.split('\n'))))) + + if is_tidy: + tmp.duplicate_go_mod() + tmp.run(["mod", "tidy"], True) + + if is_tidy or len(local_packages) > 0: + dry_run = tmp.run(["list", "-m", "all"], True) + packages.update(set(filter(None, map(line_to_package, dry_run.stdout.split('\n'))))) + + return list(packages) + except subprocess.CalledProcessError: + # An erroring command does not install anything + _log.info("The Go command encountered an error while collecting installation targets") + return [] + + def _normalize_command(self, command: list[str]) -> list[str]: + """ + Normalize a `go` command. + + Args: + command: A `list[str]` containing a `go` command line. + + Returns: + The equivalent but normalized form of `command` with the initial `go` + token replaced with the local filesystem path to `self.executable()`. + + Raises: + ValueError: The given `command` is empty or not a valid `go` command. + """ + if not command: + raise ValueError("Received empty go command line") + if command[0] != self.name(): + raise ValueError("Received invalid go command line") + + return [self._executable] + command[1:] + + def list_installed_packages(self) -> list[Package]: + """ + List all installed packages. + + Returns: + A `list[Package]` representing all currently installed packages. + """ + def line_to_package(line: str) -> Optional[Package]: + # All supported versions adhere to this format + components = line.strip().split() + if len(components) == 2: + return Package(self.ecosystem(), components[0], components[1]) + return None + + try: + go_list_cmd = [self._executable, "list", "-m", "all"] + list_cmd = subprocess.run(go_list_cmd, check=True, text=True, capture_output=True) + return list(filter(None, map(line_to_package, list_cmd.stdout.split('\n')))) + except subprocess.CalledProcessError: + raise RuntimeError("Failed to list go installed packages") + + +class GoModNotFoundError(Exception): + """ + An exception that occurs when an attempt is made to execute a go command that + must be executed within a go project (with a go.mod file), but not such file + could be found in the direct directory hierarchy. + """ + pass diff --git a/tests/package_managers/test_go.py b/tests/package_managers/test_go.py new file mode 100644 index 00000000..8db81d94 --- /dev/null +++ b/tests/package_managers/test_go.py @@ -0,0 +1,154 @@ +""" +Tests of Go's command line behavior. +""" + +import hashlib +import os +from pathlib import Path +import platform +import pytest +import subprocess +from tempfile import TemporaryDirectory + +TEST_PROJECT_NAME = "foo" + + +class GoProject: + """ + A representation of a Go project and its local environment. + """ + def __init__(self, go_dir: str, directory: str, env: dict): + self.go_dir = go_dir + self.directory = directory + self.env = env + + + def get_go_dir_contents(self) -> str: + """ + List the contents in the go directory. + + Note that this only looks at the name and path of the filesm ignoring + their actual contents! + """ + h = hashlib.new("md5") + for root, _, files in os.walk(self.go_dir): + r = Path(root) + for name in files: + h.update(bytes(r / name)) + return h.hexdigest() + + +@pytest.fixture +def new_go_project(): + """ + Initialize a clean Go project for use in testing and set it as the current directory + for the duration of the test. + """ + with TemporaryDirectory() as tmpdir: + go_dir, env = _init_go_env(tmpdir) + + project_dir = Path(tmpdir) / "project" + project_dir.mkdir(mode=0o750) + + original_dir = os.getcwd() + + _init_go_project(project_dir, env, TEST_PROJECT_NAME) + os.chdir(project_dir) + + yield GoProject(go_dir, project_dir, env) + + os.chdir(original_dir) + + +def test_go_no_change(new_go_project): + """ + Test that certain `go` commands relied on by Supply-Chain Firewall + not to error or modify the local installation state indeed have these properties. + """ + def _test_go_no_change(project: GoProject, base_go: GoProject, local_init_state: str, global_init_state: str, command: list) -> bool: + """ + Tests that a given Poetry command does not encounter any errors and does not + modify the local installation state when run in the context of a given project. + """ + # 'go * -h' exits with 2. + help_command = subprocess.run(command, check=False, cwd=project.directory, env=project.env) + assert help_command.returncode == 2 + return go_show(project) == local_init_state and base_go.get_go_dir_contents() == global_init_state + + test_cases = [] + for command in ["build", "generate", "get", "install", "mod", "run"]: + for param in ["-h", "-help"]: + test_cases.append(["go", command, param]) + + base_go = GoProject(get_gopath(), "", {}) + global_init_state = base_go.get_go_dir_contents() + + local_init_state = go_show(new_go_project) + + assert all(_test_go_no_change(new_go_project, base_go, local_init_state, global_init_state, command) for command in test_cases) + + +def go_show(project: GoProject) -> str: + """ + Get the current state of packages installed via go. + """ + go_show = subprocess.run( + ["go", "list", "-m", "all"], + check=True, + cwd=project.directory, + env=project.env, + text=True, + capture_output=True, + ) + return go_show.stdout.lower() + + +def get_gopath() -> str: + """ + Retrieve the default path where go install packages. + """ + gopath = subprocess.run(["go", "env", "GOPATH"], check=True, text=True, capture_output=True) + return gopath.stdout.strip() + + +def _init_go_env(directory) -> (Path, dict): + """ + Initialize a fresh Go environment in `directory` and return both the path + used by go and the environment variables that should be provided to + subprocess to access it. + """ + separator = ":" + if platform.system() == "Windows": + separator = ";" + + base_dir = Path(directory) / "go" + base_dir.mkdir(mode=0o750) + + go_dir = base_dir / "go" + go_dir.mkdir(mode=0o750) + + cache_dir = base_dir / "cache" + cache_dir.mkdir(mode=0o750) + + mod_cache_dir = base_dir / "mod_cache" + mod_cache_dir.mkdir(mode=0o750) + + gopath = get_gopath() + + env = os.environ.copy() + env['GOPATH'] = f"{go_dir}{separator}{gopath}" + env['GOCACHE'] = str(cache_dir) + env['GOMODCACHE'] = str(mod_cache_dir) + + return base_dir, env + + +def _init_go_project(directory, env, name, dependencies = None): + """ + Initialize a fresh Go project in `directory` with the given `dependencies`. + """ + subprocess.run(["go", "mod", "init", name], check=True, cwd=directory, env=env) + + if dependencies: + for package, version in dependencies: + subprocess.run(["go", "get", f"{package}@{version}"], check=True, cwd=directory, env=env) diff --git a/tests/package_managers/test_go_class.py b/tests/package_managers/test_go_class.py new file mode 100644 index 00000000..18f102d7 --- /dev/null +++ b/tests/package_managers/test_go_class.py @@ -0,0 +1,111 @@ +""" +Tests of Go`, the `PackageManager` subclass. +""" + +import requests +from pathlib import Path +from scfw.ecosystem import ECOSYSTEM +from scfw.package import Package +from scfw.package_managers.go import Go +import subprocess +import textwrap + +from .test_go import GoProject, get_gopath, go_show, new_go_project + +PACKAGE_MANAGER = Go() +""" +Fixed `PackageManager` to use across all tests. +""" + +TARGET = "golang.org/x/xerrors" + +LATEST_VERSION = requests.get(f"https://proxy.golang.org/{TARGET}/@latest", timeout=5).json()["Version"] + + +def test_go_command_would_install_remote(new_go_project): + """ + Tests that `Go.resolve_install_targets()` correctly resolves installation + targets for a variety of target specfications without installing anything. + """ + test_cases = [ + ["go", "get", TARGET], + ["go", "install", f"{TARGET}@latest"], + ["go", "run", f"{TARGET}@latest"], + ] + + base_go = GoProject(get_gopath(), "", {}) + global_init_state = base_go.get_go_dir_contents() + + local_init_state = go_show(new_go_project) + + for args in test_cases: + targets = PACKAGE_MANAGER.resolve_install_targets(args) + + assert ( + len(targets) == 1 + and targets[0].ecosystem == ECOSYSTEM.Go + and targets[0].name == TARGET + and targets[0].version == LATEST_VERSION + ) + assert go_show(new_go_project) == local_init_state + assert base_go.get_go_dir_contents() == global_init_state + + +def test_go_command_would_install_local(new_go_project): + """ + Tests that `Go.resolve_install_targets()` correctly resolves installation + targets for a variety of commands that inspect the local project without + installing anything. + """ + test_cases = [ + ["go", "mod", "download"], + ["go", "mod", "tidy"], + ["go", "build", "."], + ["go", "install", "."], + ] + + base_go = GoProject(get_gopath(), "", {}) + global_init_state = base_go.get_go_dir_contents() + + _install_target(new_go_project) + + local_init_state = go_show(new_go_project) + + for args in test_cases: + targets = PACKAGE_MANAGER.resolve_install_targets(args) + + assert ( + len(targets) == 1 + and targets[0].ecosystem == ECOSYSTEM.Go + and targets[0].name == TARGET + and targets[0].version == LATEST_VERSION + ) + assert go_show(new_go_project) == local_init_state + assert base_go.get_go_dir_contents() == global_init_state + + +def test_go_list_installed_packages(new_go_project): + """ + Test that `Go.list_installed_packages` correctly parses `go` output. + """ + target = Package(ECOSYSTEM.Go, TARGET, LATEST_VERSION) + _install_target(new_go_project) + assert [target] == PACKAGE_MANAGER.list_installed_packages() + + +def _install_target(project: GoProject): + """ + Install the target package to the provided directory and write a dummy source file. + """ + subprocess.run(["go", "get", f"{TARGET}@{LATEST_VERSION}"], check=True, cwd=project.directory, env=project.env) + + main = Path(project.directory, "main.go") + with main.open(mode="w") as f: + f.write(textwrap.dedent(f"""\ + package main + + import "{TARGET}" + + func main() {{ + _ = xerrors.New("error") + }}"""))