from __future__ import annotations import os import stat import tempfile import textwrap import unittest from pathlib import Path from .fixtures import isolated_environment from .nono import NonoAdapter class NonoAdapterTests(unittest.TestCase): def _environment(self, root: Path, *, home: Path | None = None) -> dict[str, str]: return { "HOME": str(home or root), "XDG_CONFIG_HOME": str(root), "XDG_STATE_HOME": str(root), "XDG_CACHE_HOME": str(root), "XDG_RUNTIME_DIR": str(root), "TMPDIR": str(root), "PATH": "/usr/bin:/bin", } def _fake_nono(self, directory: Path, log: Path) -> Path: executable = directory / "fake-nono" executable.write_text( textwrap.dedent( f"""\ #!/usr/bin/env python3 import pathlib, sys, time pathlib.Path({str(log)!r}).write_text('\\n'.join(sys.argv[1:]), encoding='utf-8') if '--version' in sys.argv: print('nono 0.74.0') if 'slow' in sys.argv: print('x' * (2 * 1024 * 1024), flush=True) print('y' * (2 * 1024 * 1024), file=sys.stderr, flush=True) time.sleep(2) print('stdout:' + ('x' * 500)) print('stderr:' + ('y' * 500), file=sys.stderr) """ ), encoding="utf-8", ) executable.chmod(0o700) return executable def test_validate_show_and_run_are_exact_argv_and_no_active_namespace(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) log = root / "argv.log" executable = self._fake_nono(root, log) profile = root / "temporary.json" profile.write_text("{}", encoding="utf-8") adapter = NonoAdapter( executable, cwd=root, environment=self._environment(root), ) adapter.validate_temporary_profile(profile) validation_argv = log.read_text(encoding="utf-8").splitlines() self.assertEqual(validation_argv[:3], ["profile", "validate", "--strict"]) self.assertEqual(Path(validation_argv[3]).name, "profile.json") self.assertNotEqual(validation_argv[3], str(profile)) adapter.show_temporary_profile(profile) show_argv = log.read_text(encoding="utf-8").splitlines() self.assertEqual(show_argv[:2], ["profile", "show"]) self.assertEqual(Path(show_argv[2]).name, "profile.json") self.assertEqual(show_argv[3:], ["--json"]) adapter.run_temporary_profile(profile, ["/usr/bin/true", "--fixed"]) run_argv = log.read_text(encoding="utf-8").splitlines() self.assertEqual(run_argv[:2], ["run", "--profile"]) self.assertEqual(Path(run_argv[2]).name, "profile.json") self.assertEqual(run_argv[3:], ["--", "/usr/bin/true", "--fixed"]) adapter.run_temporary_profile( profile, ["/usr/bin/true"], allow_cwd=True, ) cwd_argv = log.read_text(encoding="utf-8").splitlines() self.assertEqual(cwd_argv[:2], ["run", "--profile"]) self.assertEqual(Path(cwd_argv[2]).name, "profile.json") self.assertEqual(cwd_argv[3:], ["--allow-cwd", "--", "/usr/bin/true"]) active = root / "active" / ".config" / "nono" / "profiles" / "name.json" # An explicitly injected active namespace is rejected by the same # path guard used for HOME's conventional namespace. active.parent.mkdir(parents=True) os.chmod(root / "active", 0o700) adapter_with_home = NonoAdapter( executable, cwd=root, environment=self._environment(root, home=root / "active"), ) active.write_text("{}", encoding="utf-8") with self.assertRaises(ValueError): adapter_with_home.validate(active) hardlink = root / "active-hardlink.json" os.link(active, hardlink) with self.assertRaisesRegex(ValueError, "cannot alias"): adapter_with_home.validate(hardlink) def test_result_output_is_bounded_and_timeout_is_recorded(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) executable = self._fake_nono(root, root / "argv.log") profile = root / "temporary.json" profile.write_text("{}", encoding="utf-8") adapter = NonoAdapter( executable, cwd=root, timeout=0.1, max_output=32, environment=self._environment(root), ) result = adapter.run(profile, ["slow"]) self.assertTrue(result.timed_out) self.assertLessEqual(len(result.stdout), 32) self.assertLessEqual(len(result.stderr), 32) def test_commands_are_argv_only(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) executable = self._fake_nono(root, root / "argv.log") profile = root / "temporary.json" profile.write_text("{}", encoding="utf-8") adapter = NonoAdapter( executable, cwd=root, environment=self._environment(root), ) with self.assertRaises(TypeError): adapter.run(profile, "echo unsafe") # type: ignore[arg-type] def test_environment_is_required_and_symlinked_profiles_are_rejected(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) executable = self._fake_nono(root, root / "argv.log") with self.assertRaisesRegex(ValueError, "explicit isolated environment"): NonoAdapter(executable, cwd=root) for unsafe_name in ("GITHUB_TOKEN", "AWS_ACCESS_KEY_ID", "LD_PRELOAD"): with self.subTest(environment=unsafe_name): unsafe_environment = self._environment(root) unsafe_environment[unsafe_name] = "must-not-pass" with self.assertRaisesRegex(ValueError, "allowlist"): NonoAdapter( executable, cwd=root, environment=unsafe_environment, ) profile = root / "profile.json" profile.write_text("{}", encoding="utf-8") linked = root / "linked.json" linked.symlink_to(profile) adapter = NonoAdapter( executable, cwd=root, environment=self._environment(root), ) with self.assertRaisesRegex(ValueError, "symlinked"): adapter.validate(linked) fifo = root / "fifo-profile.json" os.mkfifo(fifo, mode=0o600) with self.assertRaisesRegex(ValueError, "regular file"): adapter.validate(fifo) class FixtureProfileValidationTests(unittest.TestCase): def test_fixture_directories_are_not_install_targets(self) -> None: with isolated_environment() as fixture: profile = Path(__file__).parents[1] / "fixtures" / "core" / "minimal.json" self.assertNotIn(str(profile), fixture.env.values()) self.assertTrue(profile.is_file()) self.assertNotEqual(stat.S_IMODE(profile.stat().st_mode), 0o600) if __name__ == "__main__": unittest.main()