From 3a515f6ad822796d54bb089118f979d5a1595c14 Mon Sep 17 00:00:00 2001 From: Callahan Kovacs Date: Tue, 25 Apr 2023 18:34:10 -0500 Subject: [PATCH] lint: lint snap files inside an instance Signed-off-by: Callahan Kovacs --- snapcraft/commands/lint.py | 202 ++++- snapcraft/linters/__init__.py | 3 +- snapcraft/linters/linters.py | 12 +- .../lint-file/expected-linter-output.txt | 5 + .../core22/linters/lint-file/snapcraft.yaml | 20 + .../core22/linters/lint-file/src/test.c | 7 + .../spread/core22/linters/lint-file/task.yaml | 11 +- tests/unit/commands/test_lint.py | 757 ++++++++++++++++-- 8 files changed, 941 insertions(+), 76 deletions(-) create mode 100644 tests/spread/core22/linters/lint-file/expected-linter-output.txt create mode 100644 tests/spread/core22/linters/lint-file/snapcraft.yaml create mode 100644 tests/spread/core22/linters/lint-file/src/test.c diff --git a/snapcraft/commands/lint.py b/snapcraft/commands/lint.py index f7e26531121..94900da4b87 100644 --- a/snapcraft/commands/lint.py +++ b/snapcraft/commands/lint.py @@ -18,19 +18,28 @@ import argparse import os +import shlex +import tempfile import textwrap +from contextlib import contextmanager from pathlib import Path -from shlex import join -from subprocess import CalledProcessError -from typing import Optional +from subprocess import CalledProcessError, check_output +from typing import Iterator, Optional from craft_cli import BaseCommand, emit from craft_cli.errors import ArgumentParsingError +from craft_providers.util import snap_cmd from overrides import overrides -from snapcraft import providers -from snapcraft.errors import SnapcraftError -from snapcraft.utils import get_managed_environment_home_path, is_managed_mode +from snapcraft import linters, projects, providers +from snapcraft.errors import LegacyFallback, SnapcraftError +from snapcraft.meta import snap_yaml +from snapcraft.parts.lifecycle import apply_yaml, extract_parse_info, process_yaml +from snapcraft.utils import ( + get_host_architecture, + get_managed_environment_home_path, + is_managed_mode, +) class LintCommand(BaseCommand): @@ -137,6 +146,8 @@ def _prepare_instance( :param assert_file: Optional path to assertion file to push into the instance. :param http_proxy: http proxy to add to environment :param https_proxy: https proxy to add to environment + + :raises SnapcraftError: If `snapcraft lint` fails inside the instance. """ emit.progress("Checking build provider availability.") @@ -177,18 +188,187 @@ def _prepare_instance( # run linter inside the instance command = ["snapcraft", "lint", str(snap_file_instance)] try: + emit.debug(f"running {command} in instance") with emit.pause(): instance.execute_run(command, check=True) - except CalledProcessError as err: + except CalledProcessError as error: raise SnapcraftError( - f"failed to execute {join(command)!r} in instance", - ) from err + f"failed to execute {shlex.join(command)!r} in instance", + ) from error + finally: + providers.capture_logs_from_instance(instance) - # pylint: disable-next=unused-argument def _run_linter(self, snap_file: Path, assert_file: Optional[Path]) -> None: """Run snapcraft linters on a snap file. :param snap_file: Path to snap file to lint. :param assert_file: Optional path to assertion file for the snap file. """ - emit.progress("'snapcraft lint' not implemented.", permanent=True) + # unsquash, load snap.yaml, and optionally load snapcraft.yaml + with self._unsquash_snap(snap_file) as unsquashed_snap: + snap_metadata = snap_yaml.read(unsquashed_snap) + project = self._load_project(unsquashed_snap / "snap" / "snapcraft.yaml") + + snap_install_path = self._install_snap(snap_file, assert_file, snap_metadata) + + lint_filters = self._load_lint_filters(project) + + # run the linters + issues = linters.run_linters(location=snap_install_path, lint=lint_filters) + linters.report(issues, intermediate=True) + + @contextmanager + def _unsquash_snap(self, snap_file: Path) -> Iterator[Path]: + """Unsquash a snap file to a temporary directory. + + :param snap_file: Snap package to extract. + + :yields: Path to the snap's unsquashed directory. + + :raises SnapcraftError: If the snap fails to unsquash. + """ + snap_file = snap_file.resolve() + + with tempfile.TemporaryDirectory(prefix=str(snap_file.parent)) as temp_dir: + emit.debug(f"Unsquashing snap file {snap_file.name!r}.") + + # unsquashfs [options] filesystem [directories or files to extract] options: + # -force: if file already exists then overwrite + # -dest : unsquash to + extract_command = [ + "unsquashfs", + "-force", + "-dest", + temp_dir, + str(snap_file), + ] + + try: + check_output(extract_command, text=True) + except CalledProcessError as error: + raise SnapcraftError( + f"could not unsquash snap file {snap_file.name!r}" + ) from error + + yield Path(temp_dir) + + def _load_project(self, snapcraft_yaml_file: Path) -> Optional[projects.Project]: + """Load a snapcraft Project from a snapcraft.yaml, if present. + + The snapcraft.yaml exist for snaps built with the `--enable-manifest` parameter. + + :param snapcraft_yaml_file: path to snapcraft.yaml file to load + + :returns: A Project containing the snapcraft.yaml's data or None if the yaml + file does not exist. + """ + if not snapcraft_yaml_file.exists(): + emit.debug(f"Could not find {snapcraft_yaml_file.name!r}.") + return None + + try: + # process_yaml will not parse core, core18, and core20 snaps + yaml_data = process_yaml(snapcraft_yaml_file) + except LegacyFallback as error: + raise SnapcraftError( + "can not lint snap using a base older than core22" + ) from error + + # process yaml before unmarshalling the data + arch = get_host_architecture() + yaml_data_for_arch = apply_yaml(yaml_data, arch, arch) + # discard parse-info - it is not needed + extract_parse_info(yaml_data_for_arch) + project = projects.Project.unmarshal(yaml_data_for_arch) + return project + + def _install_snap( + self, + snap_file: Path, + assert_file: Optional[Path], + snap_metadata: snap_yaml.SnapMetadata, + ) -> Path: + """Install a snap file and optional assertion file. + + If the architecture of the snap file does not match the host architecture, then + `snap install` will exit with a descriptive error. + + :param snap_file: Snap file to install. + :param assert_file: Optional assertion file to install. + :param snap_metadata: SnapMetadata from the snap file. + + :returns: Path to where snap was installed. + + :raises SnapcraftError: If the snap cannot be installed. + """ + is_dangerous = not bool(assert_file) + + if assert_file: + ack_command = snap_cmd.formulate_ack_command(assert_file) + + emit.debug(f"Installing assertion file with {shlex.join(ack_command)!r}.") + + try: + check_output(ack_command, text=True) + # if assertion fails, then install the snap dangerously + except CalledProcessError as error: + is_dangerous = True + emit.message( + f"Could not add assertions from file {assert_file.name!r}: {error}" + ) + + install_command = snap_cmd.formulate_local_install_command( + classic=bool(snap_metadata.confinement == "classic"), + dangerous=is_dangerous, + snap_path=snap_file, + ) + if snap_metadata.grade == "devel": + install_command.append("--devmode") + + emit.debug(f"Installing snap with {shlex.join(install_command)!r}.") + + try: + check_output(install_command, text=True) + except CalledProcessError as error: + raise SnapcraftError( + f"could not install snap file {snap_file.name!r}" + ) from error + + return Path("/snap") / snap_metadata.name / "current" + + def _load_lint_filters(self, project: Optional[projects.Project]) -> projects.Lint: + """Load lint filters from a Project and disable the classic linter. + + :param project: Project from the snap file, if present. + + :returns: Lint config with classic linter disabled. + """ + lint_config = projects.Lint(ignore=["classic"]) + + if project: + if project.lint: + emit.verbose("Collected lint config from 'snapcraft.yaml'.") + lint_config = project.lint + + # remove any file-specific classic filters + for item in lint_config.ignore: + if isinstance(item, dict) and "classic" in item.keys(): + lint_config.ignore.remove(item) + + # disable entire classic linter with the "classic" string + if "classic" not in lint_config.ignore: + lint_config.ignore.append("classic") + + else: + emit.verbose("No lint filters defined in 'snapcraft.yaml'.") + else: + emit.verbose( + "Not loading lint filters from 'snapcraft.yaml' because the file " + "does not exist inside the snap file." + ) + emit.verbose( + "To include 'snapcraft.yaml' in a snap file, use the parameter " + "'--enable-manifest' when building the snap." + ) + + return lint_config diff --git a/snapcraft/linters/__init__.py b/snapcraft/linters/__init__.py index 9a942d91455..2e0dee03f26 100644 --- a/snapcraft/linters/__init__.py +++ b/snapcraft/linters/__init__.py @@ -17,12 +17,11 @@ """Extension processor and related utilities.""" from .base import LinterIssue -from .linters import LinterStatus, lint_command, report, run_linters +from .linters import LinterStatus, report, run_linters __all__ = [ "LinterIssue", "LinterStatus", - "lint_command", "report", "run_linters", ] diff --git a/snapcraft/linters/linters.py b/snapcraft/linters/linters.py index ee88fc5ab54..70ca40d8380 100644 --- a/snapcraft/linters/linters.py +++ b/snapcraft/linters/linters.py @@ -22,7 +22,7 @@ import os from functools import partial from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Optional, Type +from typing import Dict, List, Optional, Type from craft_cli import emit @@ -33,10 +33,6 @@ from .classic_linter import ClassicLinter from .library_linter import LibraryLinter -if TYPE_CHECKING: - import argparse - - LinterType = Type[Linter] @@ -115,12 +111,6 @@ def _update_status(status: LinterStatus, result: LinterResult) -> LinterStatus: return status -def lint_command(parsed_args: "argparse.Namespace") -> None: - """``snapcraft lint`` command handler.""" - # XXX: obtain lint configuration - run_linters(parsed_args.snap_file, lint=None) - - def run_linters(location: Path, *, lint: Optional[projects.Lint]) -> List[LinterIssue]: """Run all the defined linters. diff --git a/tests/spread/core22/linters/lint-file/expected-linter-output.txt b/tests/spread/core22/linters/lint-file/expected-linter-output.txt new file mode 100644 index 00000000000..8335fda1787 --- /dev/null +++ b/tests/spread/core22/linters/lint-file/expected-linter-output.txt @@ -0,0 +1,5 @@ +Running linters... +Running linter: library +Lint warnings: +- library: linter-test: missing dependency 'libcaca.so.0'. (https://snapcraft.io/docs/linters-library) +- library: libpng16.so.16: unused library 'usr/lib/x86_64-linux-gnu/libpng16.so.16.37.0'. (https://snapcraft.io/docs/linters-library) diff --git a/tests/spread/core22/linters/lint-file/snapcraft.yaml b/tests/spread/core22/linters/lint-file/snapcraft.yaml new file mode 100644 index 00000000000..4c451325629 --- /dev/null +++ b/tests/spread/core22/linters/lint-file/snapcraft.yaml @@ -0,0 +1,20 @@ +name: lint-file +base: core22 +version: '0.1' +summary: Lint a packaged snapcraft file. +description: spread test + +grade: devel +confinement: strict + +parts: + my-part: + plugin: nil + source: src + build-packages: + - gcc + - libcaca-dev + stage-packages: + - libpng16-16 + override-build: + gcc -o $CRAFT_PART_INSTALL/linter-test test.c -lcaca diff --git a/tests/spread/core22/linters/lint-file/src/test.c b/tests/spread/core22/linters/lint-file/src/test.c new file mode 100644 index 00000000000..1b6f17fc0fd --- /dev/null +++ b/tests/spread/core22/linters/lint-file/src/test.c @@ -0,0 +1,7 @@ +#include "caca.h" + +int main() +{ + caca_create_canvas(80, 24); + return 0; +} diff --git a/tests/spread/core22/linters/lint-file/task.yaml b/tests/spread/core22/linters/lint-file/task.yaml index 92334e51d70..aecdce5497b 100644 --- a/tests/spread/core22/linters/lint-file/task.yaml +++ b/tests/spread/core22/linters/lint-file/task.yaml @@ -4,9 +4,14 @@ restore: | rm -f ./*.snap ./*.assert execute: | + # build the test snap destructively to save time + snapcraft + + # test the linter using a build provider unset SNAPCRAFT_BUILD_ENVIRONMENT + snapcraft lint lint-file_0.1_*.snap 2> output.txt - snap download hello + # get the lint warnings at end of the log file + sed -n '/Running linters.../,+4 p' < output.txt > linter-output.txt - # `snapcraft lint` is a no-op, but ensure it exits without error - snapcraft lint hello_*.snap + diff -u linter-output.txt expected-linter-output.txt diff --git a/tests/unit/commands/test_lint.py b/tests/unit/commands/test_lint.py index e15a7348ad1..f270ddfadeb 100644 --- a/tests/unit/commands/test_lint.py +++ b/tests/unit/commands/test_lint.py @@ -14,15 +14,73 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import shlex import sys from pathlib import Path from subprocess import CalledProcessError +from textwrap import dedent from unittest.mock import Mock, call import pytest from craft_providers.bases import BuilddBaseAlias from snapcraft import cli +from snapcraft.commands.lint import LintCommand +from snapcraft.errors import SnapcraftError +from snapcraft.meta.snap_yaml import SnapMetadata +from snapcraft.projects import Lint, Project + + +@pytest.fixture +def fake_assert_file(tmp_path): + """Returns a path to a fake assertion file.""" + return tmp_path / "test-snap.assert" + + +@pytest.fixture +def fake_snap_file(tmp_path): + """Return a path to a fake snap file.""" + return tmp_path / "test-snap.snap" + + +@pytest.fixture +def fake_snap_metadata(): + data = { + "name": "test", + "version": "1.0", + "summary": "test", + "description": "test", + "confinement": "strict", + "grade": "stable", + "architectures": ["test"], + } + return SnapMetadata.unmarshal(data) + + +@pytest.fixture +def fake_snapcraft_project(): + data = { + "name": "test-name", + "base": "core22", + "grade": "stable", + "confinement": "strict", + "description": "test description", + "version": "1.0", + "summary": "test summary", + "parts": {"part1": {"plugin": "nil"}}, + } + return Project.unmarshal(data) + + +@pytest.fixture +def mock_argv(mocker, fake_snap_file): + """Mock `snapcraft lint` cli for a snap named `test-snap.snap`.""" + return mocker.patch.object(sys, "argv", ["snapcraft", "lint", str(fake_snap_file)]) + + +@pytest.fixture +def mock_capture_logs_from_instance(mocker): + return mocker.patch("snapcraft.commands.lint.providers.capture_logs_from_instance") @pytest.fixture @@ -42,7 +100,7 @@ def mock_is_managed_mode(mocker): return mocker.patch("snapcraft.commands.lint.is_managed_mode", return_value=False) -@pytest.fixture() +@pytest.fixture def mock_provider(mocker, mock_instance, fake_provider): _mock_provider = Mock(wraps=fake_provider) mocker.patch( @@ -51,8 +109,24 @@ def mock_provider(mocker, mock_instance, fake_provider): return _mock_provider +@pytest.fixture +def mock_run_linters(mocker): + return mocker.patch( + "snapcraft.commands.lint.linters.run_linters", return_value=Mock() + ) + + +@pytest.fixture +def mock_report(mocker): + return mocker.patch("snapcraft.commands.lint.linters.report") + + def test_lint_default( emitter, + fake_assert_file, + fake_snap_file, + mock_argv, + mock_capture_logs_from_instance, mock_ensure_provider_is_available, mock_get_base_configuration, mock_instance, @@ -61,14 +135,10 @@ def test_lint_default( mocker, tmp_path, ): - """Test the lint command.""" - # create a snap file - snap_file = tmp_path / "test-snap.snap" - snap_file.touch() - mocker.patch.object(sys, "argv", ["snapcraft", "lint", str(snap_file)]) - # create an assertion file - assert_file = tmp_path / "test-snap.assert" - assert_file.touch() + """Test the lint command prepares an instance.""" + # create a snap file and assertion file + fake_snap_file.touch() + fake_assert_file.touch() cli.run() @@ -80,8 +150,8 @@ def test_lint_default( instance_name="snapcraft-linter", ) assert mock_instance.push_file.mock_calls == [ - call(source=snap_file, destination=Path("/root/test-snap.snap")), - call(source=assert_file, destination=Path("/root/test-snap.assert")), + call(source=fake_snap_file, destination=Path("/root/test-snap.snap")), + call(source=fake_assert_file, destination=Path("/root/test-snap.assert")), ] mock_instance.execute_run.assert_called_once_with( ["snapcraft", "lint", "/root/test-snap.snap"], check=True @@ -89,9 +159,7 @@ def test_lint_default( emitter.assert_interactions( [ call("progress", "Running linter.", permanent=True), - call( - "debug", f"Found assertion file {str(tmp_path / 'test-snap.assert')!r}." - ), + call("debug", f"Found assertion file {str(fake_assert_file)!r}."), call("progress", "Checking build provider availability."), call("progress", "Launching instance."), ] @@ -100,6 +168,8 @@ def test_lint_default( def test_lint_http_https_proxy( emitter, + fake_snap_file, + mock_capture_logs_from_instance, mock_ensure_provider_is_available, mock_get_base_configuration, mock_instance, @@ -110,15 +180,16 @@ def test_lint_http_https_proxy( ): """Pass the http and https proxies into the instance.""" # create a snap file - snap_file = tmp_path / "test-snap.snap" - snap_file.touch() + fake_snap_file.touch() + + # mock command mocker.patch.object( sys, "argv", [ "snapcraft", "lint", - str(snap_file), + str(fake_snap_file), "--http-proxy", "test-http-proxy", "--https-proxy", @@ -138,110 +209,105 @@ def test_lint_http_https_proxy( def test_lint_assert_file_missing( emitter, + fake_assert_file, + fake_snap_file, + mock_argv, + mock_capture_logs_from_instance, mock_ensure_provider_is_available, mock_get_base_configuration, mock_instance, mock_is_managed_mode, mock_provider, - mocker, tmp_path, ): """Do not push non-existent assertion files in the instance.""" # create a snap file - snap_file = tmp_path / "test-snap.snap" - snap_file.touch() - mocker.patch.object(sys, "argv", ["snapcraft", "lint", str(snap_file)]) + fake_snap_file.touch() cli.run() mock_instance.push_file.assert_called_once_with( - source=snap_file, + source=fake_snap_file, destination=Path("/root/test-snap.snap"), ) mock_instance.execute_run.assert_called_once_with( ["snapcraft", "lint", "/root/test-snap.snap"], check=True ) - emitter.assert_debug( - f"Assertion file {str(tmp_path / 'test-snap.assert')!r} does not exist." - ) + emitter.assert_debug(f"Assertion file {str(fake_assert_file)!r} does not exist.") def test_lint_assert_file_not_valid( emitter, + fake_assert_file, + fake_snap_file, + mock_argv, + mock_capture_logs_from_instance, mock_ensure_provider_is_available, mock_get_base_configuration, mock_instance, mock_is_managed_mode, mock_provider, - mocker, tmp_path, ): """Do not push invalid assertion files in the instance.""" # create a snap file - snap_file = tmp_path / "test-snap.snap" - snap_file.touch() - mocker.patch.object(sys, "argv", ["snapcraft", "lint", str(snap_file)]) + fake_snap_file.touch() + # make the assertion filepath a directory - assert_file = tmp_path / "test-snap.assert" - assert_file.mkdir() + fake_assert_file.mkdir() cli.run() mock_instance.push_file.assert_called_once_with( - source=snap_file, + source=fake_snap_file, destination=Path("/root/test-snap.snap"), ) mock_instance.execute_run.assert_called_once_with( ["snapcraft", "lint", "/root/test-snap.snap"], check=True ) emitter.assert_debug( - f"Assertion file {str(tmp_path / 'test-snap.assert')!r} is not a valid file." + f"Assertion file {str(fake_assert_file)!r} is not a valid file." ) -def test_lint_default_snap_file_missing(capsys, mocker, tmp_path): +def test_lint_default_snap_file_missing(capsys, fake_snap_file, mock_argv, tmp_path): """Raise an error if the snap file does not exist.""" - # do not create a snap file - snap_file = tmp_path / "test-snap.snap" - mocker.patch.object(sys, "argv", ["snapcraft", "lint", str(snap_file)]) - cli.run() out, err = capsys.readouterr() assert not out - assert f"snap file {str(snap_file)!r} does not exist" in err + assert f"snap file {str(fake_snap_file)!r} does not exist" in err -def test_lint_default_snap_file_not_valid(capsys, mocker, tmp_path): +def test_lint_default_snap_file_not_valid(capsys, fake_snap_file, mock_argv, tmp_path): """Raise an error if the snap file is not valid.""" # make the snap filepath a directory - snap_file = tmp_path / "test-snap.snap" - snap_file.mkdir() - mocker.patch.object(sys, "argv", ["snapcraft", "lint", str(snap_file)]) + fake_snap_file.mkdir() cli.run() out, err = capsys.readouterr() assert not out - assert f"snap file {str(snap_file)!r} is not a valid file" in err + assert f"snap file {str(fake_snap_file)!r} is not a valid file" in err -def test_lint_execute_run( +def test_lint_execute_run_error( capsys, emitter, + fake_snap_file, + mock_argv, + mock_capture_logs_from_instance, mock_ensure_provider_is_available, mock_get_base_configuration, mock_instance, mock_is_managed_mode, mock_provider, - mocker, tmp_path, ): """Raise an error if running snapcraft in the instance fails.""" # create a snap file - snap_file = tmp_path / "test-snap.snap" - snap_file.touch() - mocker.patch.object(sys, "argv", ["snapcraft", "lint", str(snap_file)]) + fake_snap_file.touch() + mock_instance.execute_run.side_effect = CalledProcessError(cmd="err", returncode=1) cli.run() @@ -249,3 +315,596 @@ def test_lint_execute_run( out, err = capsys.readouterr() assert not out assert "failed to execute 'snapcraft lint /root/test-snap.snap' in instance" in err + + +@pytest.mark.parametrize("confinement", ["classic", "strict"]) +@pytest.mark.parametrize("grade", ["devel", "stable"]) +def test_lint_managed_mode( + confinement, + emitter, + fake_assert_file, + fake_process, + fake_snap_file, + fake_snap_metadata, + fake_snapcraft_project, + grade, + mock_argv, + mock_is_managed_mode, + mock_report, + mock_run_linters, + mocker, + tmp_path, +): + """Run the linter in managed mode.""" + mock_is_managed_mode.return_value = True + + # create a snap file + fake_snap_file.touch() + + # register subprocess calls + fake_process.register_subprocess( + ["unsquashfs", "-force", "-dest", fake_process.any(), str(fake_snap_file)] + ) + + # build snap install command + command = ["snap", "install", str(fake_snap_file)] + if confinement == "classic": + command.append("--classic") + command.append("--dangerous") + if grade == "devel": + command.append("--devmode") + fake_process.register_subprocess(command) + + # mock data from the unsquashed snap + fake_snap_metadata.confinement = confinement + fake_snap_metadata.grade = grade + mocker.patch( + "snapcraft.commands.lint.snap_yaml.read", return_value=fake_snap_metadata + ) + mocker.patch( + "snapcraft.commands.lint.LintCommand._load_project", + return_value=fake_snapcraft_project, + ) + + cli.run() + + mock_run_linters.assert_called_once_with( + lint=Lint(ignore=["classic"]), + location=Path("/snap/test/current"), + ) + mock_report.assert_called_once_with( + mock_run_linters.return_value, intermediate=True + ) + emitter.assert_interactions( + [ + call("progress", "Running linter.", permanent=True), + call("debug", f"Assertion file {str(fake_assert_file)!r} does not exist."), + call("debug", f"Unsquashing snap file {fake_snap_file.name!r}."), + call("debug", f"Installing snap with {shlex.join(command)!r}."), + call("verbose", "No lint filters defined in 'snapcraft.yaml'."), + ] + ) + + +def test_lint_managed_mode_without_snapcraft_yaml( + emitter, + fake_assert_file, + fake_process, + fake_snap_file, + fake_snap_metadata, + fake_snapcraft_project, + mock_argv, + mock_is_managed_mode, + mock_report, + mock_run_linters, + mocker, + tmp_path, +): + """Run the linter in managed mode without a snapcraft.yaml file.""" + mock_is_managed_mode.return_value = True + + # create a snap file + fake_snap_file.touch() + + # register subprocess calls + fake_process.register_subprocess( + ["unsquashfs", "-force", "-dest", fake_process.any(), str(fake_snap_file)] + ) + fake_process.register_subprocess( + ["snap", "install", str(fake_snap_file), "--dangerous"] + ) + + # mock data from the unsquashed snap + mocker.patch( + "snapcraft.commands.lint.snap_yaml.read", return_value=fake_snap_metadata + ) + mocker.patch( + "snapcraft.commands.lint.LintCommand._load_project", + return_value=None, + ) + + cli.run() + + mock_run_linters.assert_called_once_with( + lint=Lint(ignore=["classic"]), + location=Path("/snap/test/current"), + ) + mock_report.assert_called_once_with( + mock_run_linters.return_value, intermediate=True + ) + emitter.assert_interactions( + [ + call("progress", "Running linter.", permanent=True), + call("debug", f"Assertion file {str(fake_assert_file)!r} does not exist."), + call("debug", f"Unsquashing snap file {fake_snap_file.name!r}."), + call( + "debug", + f"Installing snap with 'snap install {str(fake_snap_file)} " + "--dangerous'.", + ), + call( + "verbose", + "Not loading lint filters from 'snapcraft.yaml' because the file does " + "not exist inside the snap file.", + ), + call( + "verbose", + "To include 'snapcraft.yaml' in a snap file, use the parameter " + "'--enable-manifest' when building the snap.", + ), + ] + ) + + +def test_lint_managed_mode_unsquash_error( + capsys, + emitter, + fake_process, + fake_snap_file, + fake_snap_metadata, + fake_snapcraft_project, + mock_argv, + mock_is_managed_mode, + mock_report, + mock_run_linters, + mocker, + tmp_path, +): + """Raise an error if the snap file cannot be installed.""" + mock_is_managed_mode.return_value = True + + # create a snap file + fake_snap_file.touch() + + # register subprocess calls + fake_process.register_subprocess( + ["unsquashfs", "-force", "-dest", fake_process.any(), str(fake_snap_file)], + returncode=1, + ) + + # mock data from the unsquashed snap + mocker.patch( + "snapcraft.commands.lint.snap_yaml.read", return_value=fake_snap_metadata + ) + mocker.patch( + "snapcraft.commands.lint.LintCommand._load_project", + return_value=fake_snapcraft_project, + ) + + cli.run() + + out, err = capsys.readouterr() + assert not out + assert f"could not unsquash snap file {fake_snap_file.name!r}" in err + + +def test_lint_managed_mode_snap_install_error( + capsys, + emitter, + fake_process, + fake_snap_file, + fake_snap_metadata, + fake_snapcraft_project, + mock_argv, + mock_is_managed_mode, + mock_report, + mock_run_linters, + mocker, + tmp_path, +): + """Raise an error if the snap file cannot be installed.""" + mock_is_managed_mode.return_value = True + + # create a snap file + fake_snap_file.touch() + + # register subprocess calls + fake_process.register_subprocess( + ["unsquashfs", "-force", "-dest", fake_process.any(), str(fake_snap_file)] + ) + fake_process.register_subprocess( + ["snap", "install", str(fake_snap_file), "--dangerous"], returncode=1 + ) + + # mock data from the unsquashed snap + mocker.patch( + "snapcraft.commands.lint.snap_yaml.read", return_value=fake_snap_metadata + ) + mocker.patch( + "snapcraft.commands.lint.LintCommand._load_project", + return_value=fake_snapcraft_project, + ) + + cli.run() + + out, err = capsys.readouterr() + assert not out + assert f"could not install snap file {fake_snap_file.name!r}" in err + + +def test_lint_managed_mode_assert( + emitter, + fake_assert_file, + fake_process, + fake_snap_file, + fake_snap_metadata, + fake_snapcraft_project, + mock_argv, + mock_is_managed_mode, + mock_report, + mock_run_linters, + mocker, + tmp_path, +): + """Run the linter in managed mode with an assert file.""" + mock_is_managed_mode.return_value = True + + # create a snap file and assertion file + fake_snap_file.touch() + fake_assert_file.touch() + + # register subprocess calls + fake_process.register_subprocess( + ["unsquashfs", "-force", "-dest", fake_process.any(), str(fake_snap_file)] + ) + fake_process.register_subprocess(["snap", "ack", str(fake_assert_file)]) + fake_process.register_subprocess(["snap", "install", str(fake_snap_file)]) + + # mock data from the unsquashed snap + mocker.patch( + "snapcraft.commands.lint.snap_yaml.read", return_value=fake_snap_metadata + ) + mocker.patch( + "snapcraft.commands.lint.LintCommand._load_project", + return_value=fake_snapcraft_project, + ) + + cli.run() + + mock_run_linters.assert_called_once_with( + lint=Lint(ignore=["classic"]), + location=Path("/snap/test/current"), + ) + mock_report.assert_called_once_with( + mock_run_linters.return_value, intermediate=True + ) + emitter.assert_interactions( + [ + call("progress", "Running linter.", permanent=True), + call("debug", f"Found assertion file {str(fake_assert_file)!r}."), + call("debug", "Unsquashing snap file 'test-snap.snap'."), + call( + "debug", + f"Installing assertion file with 'snap ack {fake_assert_file}'.", + ), + call("debug", f"Installing snap with 'snap install {fake_snap_file}'."), + call("verbose", "No lint filters defined in 'snapcraft.yaml'."), + ] + ) + + +def test_lint_managed_mode_assert_error( + emitter, + fake_assert_file, + fake_process, + fake_snap_file, + fake_snap_metadata, + fake_snapcraft_project, + mock_argv, + mock_is_managed_mode, + mock_report, + mock_run_linters, + mocker, + tmp_path, +): + """If the assert file fails to be installed, install the snap dangerously.""" + mock_is_managed_mode.return_value = True + + # create a snap file and assertion file + fake_snap_file.touch() + fake_assert_file.touch() + + # register subprocess calls + fake_process.register_subprocess( + ["unsquashfs", "-force", "-dest", fake_process.any(), str(fake_snap_file)] + ) + fake_process.register_subprocess( + ["snap", "ack", str(fake_assert_file)], returncode=1 + ) + fake_process.register_subprocess( + ["snap", "install", str(fake_snap_file), "--dangerous"] + ) + + # mock data from the unsquashed snap + mocker.patch( + "snapcraft.commands.lint.snap_yaml.read", return_value=fake_snap_metadata + ) + mocker.patch( + "snapcraft.commands.lint.LintCommand._load_project", + return_value=fake_snapcraft_project, + ) + + cli.run() + + mock_run_linters.assert_called_once_with( + lint=Lint(ignore=["classic"]), + location=Path("/snap/test/current"), + ) + mock_report.assert_called_once_with( + mock_run_linters.return_value, intermediate=True + ) + emitter.assert_interactions( + [ + call("progress", "Running linter.", permanent=True), + call("debug", f"Found assertion file {str(fake_assert_file)!r}."), + call("debug", "Unsquashing snap file 'test-snap.snap'."), + call( + "debug", + f"Installing assertion file with 'snap ack {fake_assert_file}'.", + ), + call( + "message", + f"Could not add assertions from file {fake_assert_file.name!r}: " + f"Command '['snap', 'ack', {str(fake_assert_file)!r}]' returned " + "non-zero exit status 1.", + ), + call( + "debug", + f"Installing snap with 'snap install {fake_snap_file} --dangerous'.", + ), + call("verbose", "No lint filters defined in 'snapcraft.yaml'."), + ] + ) + + +@pytest.mark.parametrize( + ["project_lint", "expected_lint"], + [ + ( + Lint(ignore=[]), + Lint(ignore=["classic"]), + ), + ( + Lint(ignore=["library"]), + Lint(ignore=["library", "classic"]), + ), + ( + Lint(ignore=["library", "classic"]), + Lint(ignore=["library", "classic"]), + ), + ( + Lint(ignore=[{"classic": ["bin/test1", "bin/test2"]}]), + Lint(ignore=["classic"]), + ), + ( + Lint(ignore=["library", {"classic": ["bin/test1", "bin/test2"]}]), + Lint(ignore=["library", "classic"]), + ), + ( + Lint( + ignore=["library", "classic", {"classic": ["bin/test1", "bin/test2"]}] + ), + Lint(ignore=["library", "classic"]), + ), + ], +) +def test_lint_managed_mode_with_lint_config( + emitter, + expected_lint, + fake_snap_file, + fake_snap_metadata, + fake_snapcraft_project, + mock_argv, + mock_is_managed_mode, + mock_report, + mock_run_linters, + mocker, + project_lint, + tmp_path, +): + """Run the linter in managed mode and process the lint config from the project.""" + mock_is_managed_mode.return_value = True + mocker.patch("snapcraft.commands.lint.check_output") + + # create a snap file + fake_snap_file.touch() + + # mock data from the unsquashed snap + mocker.patch( + "snapcraft.commands.lint.snap_yaml.read", return_value=fake_snap_metadata + ) + + # add a lint config to the project + fake_snapcraft_project.lint = project_lint + mocker.patch( + "snapcraft.commands.lint.LintCommand._load_project", + return_value=fake_snapcraft_project, + ) + + cli.run() + + # lint config from project should be passed to `run_linter()` + mock_run_linters.assert_called_once_with( + lint=expected_lint, location=Path("/snap/test/current") + ) + mock_report.assert_called_once_with( + mock_run_linters.return_value, intermediate=True + ) + emitter.assert_verbose("Collected lint config from 'snapcraft.yaml'.") + + +def test_load_project(fake_snapcraft_project, tmp_path): + """Load a simple snapcraft.yaml project. + + To simplify the unit tests, the `_load_project()` method is mocked out of the other + tests and tested separately. + """ + # create a simple snapcraft.yaml + (tmp_path / "snap").mkdir() + snap_file = tmp_path / "snap/snapcraft.yaml" + with snap_file.open("w") as yaml_file: + print( + dedent( + """\ + name: test-name + version: "1.0" + summary: test summary + description: test description + base: core22 + confinement: strict + grade: stable + + parts: + part1: + plugin: nil + """ + ), + file=yaml_file, + ) + + result = LintCommand(None)._load_project(snapcraft_yaml_file=snap_file) + + assert result == fake_snapcraft_project + + +@pytest.mark.usefixtures("fake_extension") +def test_load_project_complex(mocker, tmp_path): + """Load a complex snapcraft file. + + This includes lint, parse-info, architectures, and advanced grammar. + """ + # mock for advanced grammar parsing (i.e. `on amd64:`) + mocker.patch("snapcraft.commands.lint.get_host_architecture", return_value="amd64") + + # create a snap file + (tmp_path / "snap").mkdir() + snap_file = tmp_path / "snap/snapcraft.yaml" + with snap_file.open("w") as yaml_file: + print( + dedent( + """\ + name: test-name + version: "1.0" + summary: test summary + description: test description + base: core22 + confinement: strict + grade: stable + architectures: [amd64, arm64, armhf] + + apps: + app1: + command: app1 + command-chain: [fake-command] + extensions: [fake-extension] + + parts: + nil: + plugin: nil + parse-info: + - usr/share/metainfo/app1.appdata.xml + stage-packages: + - mesa-opencl-icd + - ocl-icd-libopencl1 + - on amd64: + - intel-opencl-icd + """ + ), + file=yaml_file, + ) + + result = LintCommand(None)._load_project(snapcraft_yaml_file=snap_file) + assert result == Project.unmarshal( + { + "name": "test-name", + "base": "core22", + "build_base": "core22", + "version": "1.0", + "summary": "test summary", + "description": "test description", + "confinement": "strict", + "grade": "stable", + "architectures": [{"build_on": ["amd64"], "build_for": ["amd64"]}], + "apps": { + "app1": { + "command": "app1", + "plugs": ["fake-plug"], + "command-chain": ["fake-command"], + } + }, + "parts": { + "nil": { + "plugin": "nil", + "stage-packages": [ + "mesa-opencl-icd", + "ocl-icd-libopencl1", + "intel-opencl-icd", + ], + "after": ["fake-extension/fake-part"], + }, + "fake-extension/fake-part": {"plugin": "nil"}, + }, + } + ) + + +def test_load_project_no_file(emitter, tmp_path): + """Return None if there is no snapcraft.yaml file.""" + snapcraft_yaml_file = tmp_path / "snap/snapcraft.yaml" + + result = LintCommand(None)._load_project(snapcraft_yaml_file=snapcraft_yaml_file) + + assert not result + emitter.assert_debug(f"Could not find {snapcraft_yaml_file.name!r}.") + + +@pytest.mark.parametrize("base", ["core", "core18", "core20"]) +def test_load_project_unsupported_core_error(base, tmp_path): + """Raise an error if for snaps with core, core18, and core20 bases.""" + # create a simple snapcraft.yaml + (tmp_path / "snap").mkdir() + snap_file = tmp_path / "snap/snapcraft.yaml" + with snap_file.open("w") as yaml_file: + print( + dedent( + f"""\ + name: test-name + version: "1.0" + summary: test summary + description: test description + base: {base} + confinement: strict + grade: stable + + parts: + part1: + plugin: nil + """ + ), + file=yaml_file, + ) + + with pytest.raises(SnapcraftError) as raised: + LintCommand(None)._load_project(snapcraft_yaml_file=snap_file) + + assert str(raised.value) == "can not lint snap using a base older than core22"