refactor: fail loud, tighten types, remove speculative abstraction

Fail loud at the boundary:
- substitute_template raises ConfigError on unresolved {{...}}; no more
  silent literal placeholders in download URLs.
- parse_profile raises ConfigError when 'os' is missing -- no
  raw.get("os", "linux") default that silently masks typos.
- urllib download failures wrapped to FlowError.
- bootstrap _execute_action dispatches phases explicitly and raises
  on unhandled phase; no more "anything else runs as shell".

Direct access over defensive wrapping:
- plan_bootstrap requires env; plan_install requires pm. Drop the
  dead `or os.environ` / `or detect_package_manager()` fallbacks.
- InstalledState.from_dict raises ConfigError on missing fields
  rather than .get(..., default).
- Replace `x or {}` chains with explicit `x if x is not None else {}`
  in package resolution; catalog validates type/platform-map/install
  shapes at parse.

One canonical form / direct access:
- Path.home() replaced with paths.HOME in services/packages.py and
  commands/completion.py. paths.HOME is the single source now.
- Use Path.is_relative_to for install-path containment instead of
  str.startswith.

Domain purity:
- domain/containers/resolution.resolve_mounts takes a filesystem_check
  predicate; service passes the probe in. Domain no longer touches
  the filesystem directly.

No speculative abstraction:
- Drop the `allow_sudo` field entirely. The _script_uses_sudo check
  it gated was bypassable (substring match) and gave false confidence;
  the manifest is fully user-trusted anyway.
- Delete dead terminfo_fix_command + RemoteService.fix_terminfo
  (no command surface exposes them).
- FileSystem.remove_tree no longer swallows errors via ignore_errors;
  callers opt into missing_ok if needed.

Typed enums:
- PackageDef.type, AppConfig.container_runtime as Literal[...].
  container_runtime values validated at config parse.

Completion bypasses runtime no longer:
- complete(ctx, ...) threads context; ContainerRuntime and state-file
  reads go through ctx.runtime instead of constructing primitives.

Tests added for: template raise, missing os raise, env/pm required,
unknown phase raise, no allow_sudo gate, URL download failure, install
path escape, corrupt installed.json, container_runtime Literal,
filesystem_check controls mounts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 00:02:06 +03:00
parent c0e2758057
commit a71742afee
26 changed files with 429 additions and 214 deletions

View File

@@ -3,10 +3,24 @@
import subprocess
from flow.commands.completion import complete
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
def _make_ctx():
return FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=SystemRuntime(),
)
def test_complete_top_level():
result = complete(["flow", ""], 1)
result = complete(_make_ctx(), ["flow", ""], 1)
assert "dotfiles" in result
assert "packages" in result
assert "setup" in result
@@ -16,12 +30,12 @@ def test_complete_top_level():
def test_complete_top_level_prefix():
result = complete(["flow", "do"], 1)
result = complete(_make_ctx(), ["flow", "do"], 1)
assert result == ["dotfiles"]
def test_complete_dotfiles_subcommands():
result = complete(["flow", "dotfiles", ""], 2)
result = complete(_make_ctx(), ["flow", "dotfiles", ""], 2)
assert "link" in result
assert "unlink" in result
assert "status" in result
@@ -36,7 +50,7 @@ def test_complete_dotfiles_subcommands():
def test_complete_dotfiles_repos_subcommands():
result = complete(["flow", "dotfiles", "repos", ""], 3)
result = complete(_make_ctx(), ["flow", "dotfiles", "repos", ""], 3)
assert "list" in result
assert "status" in result
assert "pull" in result
@@ -44,43 +58,41 @@ def test_complete_dotfiles_repos_subcommands():
def test_complete_dotfiles_repos_pull_flags():
result = complete(["flow", "dotfiles", "repos", "pull", "--"], 4)
result = complete(_make_ctx(), ["flow", "dotfiles", "repos", "pull", "--"], 4)
assert "--repo" in result
assert "--dry-run" in result
def test_complete_dotfiles_edit_packages():
result = complete(["flow", "dotfiles", "edit", "--"], 3)
result = complete(_make_ctx(), ["flow", "dotfiles", "edit", "--"], 3)
assert "--no-commit" in result
def test_complete_dotfiles_link_flags():
result = complete(["flow", "dotfiles", "link", "--"], 3)
result = complete(_make_ctx(), ["flow", "dotfiles", "link", "--"], 3)
assert "--profile" in result
assert "--dry-run" in result
def test_complete_unknown_command():
result = complete(["flow", "unknown", ""], 2)
result = complete(_make_ctx(), ["flow", "unknown", ""], 2)
assert result == []
def test_complete_packages_subcommands():
result = complete(["flow", "packages", ""], 2)
result = complete(_make_ctx(), ["flow", "packages", ""], 2)
assert "install" in result
assert "remove" in result
assert "list" in result
def test_complete_dev_attach_returns_empty_on_timeout(monkeypatch):
class FakeRuntime:
def __init__(self, runner, *, mode="auto"):
self.runner = runner
def test_complete_dev_attach_returns_empty_on_timeout():
ctx = _make_ctx()
def ps(self, **kwargs):
assert kwargs["timeout"] == 1.0
raise subprocess.TimeoutExpired("docker ps", kwargs["timeout"])
def fake_ps(**kwargs):
assert kwargs["timeout"] == 1.0
raise subprocess.TimeoutExpired("docker ps", kwargs["timeout"])
monkeypatch.setattr("flow.commands.completion.ContainerRuntime", FakeRuntime)
result = complete(["flow", "dev", "attach", ""], 3)
ctx.runtime.containers.ps = fake_ps # type: ignore[method-assign]
result = complete(ctx, ["flow", "dev", "attach", ""], 3)
assert result == []

View File

@@ -5,6 +5,7 @@ from pathlib import Path
import pytest
from flow.core.config import AppConfig, load_config, load_manifest
from flow.core.errors import ConfigError
def test_load_config_missing_path(tmp_path):
@@ -141,3 +142,22 @@ def test_load_config_container_runtime(tmp_path):
)
cfg = load_config(tmp_path)
assert cfg.container_runtime == "podman-rootful"
@pytest.mark.parametrize("runtime", ["auto", "docker", "podman", "podman-rootful"])
def test_load_config_container_runtime_accepts_known_values(tmp_path, runtime):
(tmp_path / "config.yaml").write_text(
"defaults:\n"
f" container-runtime: {runtime}\n"
)
cfg = load_config(tmp_path)
assert cfg.container_runtime == runtime
def test_load_config_container_runtime_rejects_unknown(tmp_path):
(tmp_path / "config.yaml").write_text(
"defaults:\n"
" container-runtime: nspawn\n"
)
with pytest.raises(ConfigError, match="Invalid container-runtime"):
load_config(tmp_path)

View File

@@ -1,5 +1,7 @@
"""Tests for bootstrap planning."""
import inspect
import pytest
from flow.core.errors import ConfigError
@@ -22,33 +24,45 @@ class TestParseProfile:
assert profile.shell == "zsh"
assert len(profile.packages) == 2
def test_defaults(self):
profile = parse_profile("minimal", {})
def test_missing_os_raises(self):
with pytest.raises(ConfigError, match=r"Profile 'minimal': required field 'os' is missing"):
parse_profile("minimal", {})
def test_optional_fields_default(self):
profile = parse_profile("minimal", {"os": "linux"})
assert profile.os == "linux"
assert profile.hostname is None
assert profile.packages == ()
def test_ssh_keys(self):
raw = {"ssh-keys": [{"path": "~/.ssh/id_ed25519", "type": "ed25519"}]}
raw = {"os": "linux", "ssh-keys": [{"path": "~/.ssh/id_ed25519", "type": "ed25519"}]}
profile = parse_profile("test", raw)
assert len(profile.ssh_keys) == 1
def test_ssh_keys_with_filename(self):
raw = {"ssh-keys": [{"filename": "id_work", "type": "ed25519"}]}
raw = {"os": "linux", "ssh-keys": [{"filename": "id_work", "type": "ed25519"}]}
profile = parse_profile("test", raw)
assert profile.ssh_keys[0]["path"] == "~/.ssh/id_work"
def test_env_required(self):
profile = parse_profile("test", {"env-required": ["USER_EMAIL"]})
profile = parse_profile("test", {"os": "linux", "env-required": ["USER_EMAIL"]})
assert profile.env_required == ("USER_EMAIL",)
def test_post_link_and_dotfiles_profile(self):
profile = parse_profile("test", {"dotfiles-profile": "linux-work", "post-link": "echo done"})
profile = parse_profile(
"test",
{"os": "linux", "dotfiles-profile": "linux-work", "post-link": "echo done"},
)
assert profile.dotfiles_profile == "linux-work"
assert profile.post_link == "echo done"
class TestPlanBootstrap:
def test_env_is_required_keyword(self):
sig = inspect.signature(plan_bootstrap)
param = sig.parameters["env"]
assert param.default is inspect.Parameter.empty
def test_basic_plan(self):
profile = Profile(
name="test", os="linux", arch=None,
@@ -57,7 +71,7 @@ class TestPlanBootstrap:
packages=["fd"], env_required=[],
)
manifest = {"packages": [{"name": "fd", "type": "pkg"}]}
plan = plan_bootstrap(profile, manifest)
plan = plan_bootstrap(profile, manifest, env={})
assert plan.profile == "test"
assert plan.total_steps > 0
phases = [a.phase for a in plan.actions]
@@ -65,8 +79,7 @@ class TestPlanBootstrap:
assert "packages" in phases
assert "dotfiles" in phases
def test_missing_env_raises(self, monkeypatch):
monkeypatch.delenv("REQUIRED_VAR", raising=False)
def test_missing_env_raises(self):
profile = Profile(
name="test", os="linux", arch=None,
hostname=None, locale=None, shell=None,
@@ -74,7 +87,7 @@ class TestPlanBootstrap:
env_required=["REQUIRED_VAR"],
)
with pytest.raises(ConfigError, match="REQUIRED_VAR"):
plan_bootstrap(profile, {})
plan_bootstrap(profile, {}, env={})
def test_runcmd_produces_action(self):
profile = Profile(
@@ -83,7 +96,7 @@ class TestPlanBootstrap:
ssh_keys=[], runcmd=["echo hello", "echo world"],
packages=[], env_required=[],
)
plan = plan_bootstrap(profile, {})
plan = plan_bootstrap(profile, {}, env={})
runcmd_actions = [a for a in plan.actions if "custom command" in a.description.lower()]
assert len(runcmd_actions) == 1
@@ -94,7 +107,7 @@ class TestPlanBootstrap:
ssh_keys=[], runcmd=[], packages=[], env_required=[],
post_link="echo done",
)
plan = plan_bootstrap(profile, {})
plan = plan_bootstrap(profile, {}, env={})
assert any(action.phase == "post-link" for action in plan.actions)
def test_ssh_keys_action(self):
@@ -104,6 +117,6 @@ class TestPlanBootstrap:
ssh_keys=[{"path": "~/.ssh/id", "type": "ed25519"}],
runcmd=[], packages=[], env_required=[],
)
plan = plan_bootstrap(profile, {})
plan = plan_bootstrap(profile, {}, env={})
ssh_actions = [a for a in plan.actions if "SSH" in a.description]
assert len(ssh_actions) == 1

View File

@@ -42,28 +42,40 @@ class TestResolveMounts:
def test_projects_mount(self, tmp_path):
projects = tmp_path / "projects"
projects.mkdir()
mounts = resolve_mounts(tmp_path, project_path=str(projects))
mounts = resolve_mounts(
tmp_path, filesystem_check=lambda p: p.exists(), project_path=str(projects),
)
project_mounts = [m for m in mounts if m.target == "/workspace"]
assert len(project_mounts) == 1
def test_dotfiles_mount(self, tmp_path):
dotfiles = tmp_path / "dotfiles"
dotfiles.mkdir()
mounts = resolve_mounts(tmp_path, dotfiles_dir=dotfiles)
mounts = resolve_mounts(
tmp_path, filesystem_check=lambda p: p.exists(), dotfiles_dir=dotfiles,
)
assert any(m.target.endswith("/flow/dotfiles") for m in mounts)
def test_socket_path_mount(self, tmp_path):
sock = tmp_path / "docker.sock"
sock.write_text("")
mounts = resolve_mounts(tmp_path, socket_path=sock)
mounts = resolve_mounts(
tmp_path, filesystem_check=lambda p: p.exists(), socket_path=sock,
)
socket_mounts = [m for m in mounts if m.target == "/var/run/docker.sock"]
assert len(socket_mounts) == 1
assert socket_mounts[0].source == sock
def test_no_socket_path(self, tmp_path):
mounts = resolve_mounts(tmp_path)
mounts = resolve_mounts(tmp_path, filesystem_check=lambda p: p.exists())
assert not any(m.target == "/var/run/docker.sock" for m in mounts)
def test_filesystem_check_controls_standard_mounts(self, tmp_path):
mounts = resolve_mounts(tmp_path, filesystem_check=lambda p: False)
# No standard mounts present when filesystem_check returns False.
assert not any(m.target == "/home/dev/.ssh" for m in mounts)
assert not any(m.target.endswith("/flow/dotfiles") for m in mounts)
class TestBuildContainerSpec:
def test_basic(self):

View File

@@ -67,7 +67,7 @@ class TestResolveSpec:
name="fd", type="pkg", sources={"apt": "fd-find"},
source=None, version=None, asset_pattern=None,
platform_map={}, extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)}
ref = ProfilePackageRef(name="fd", type=None, source=None, version="1.0", asset_pattern=None)
result = resolve_spec(ref, catalog)
@@ -86,7 +86,7 @@ class TestResolveSpec:
name="docker", type="pkg", sources={"apt": "docker-ce"},
source=None, version=None, asset_pattern=None,
platform_map={}, extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)}
ref = ProfilePackageRef(
name="docker",
@@ -95,11 +95,9 @@ class TestResolveSpec:
version=None,
asset_pattern=None,
post_install="sudo groupadd docker || true",
allow_sudo=True,
)
result = resolve_spec(ref, catalog)
assert result.post_install == "sudo groupadd docker || true"
assert result.allow_sudo is True
class TestResolveSourceName:
@@ -108,7 +106,7 @@ class TestResolveSourceName:
name="fd", type="pkg", sources={"apt": "fd-find"},
source=None, version=None, asset_pattern=None,
platform_map={}, extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)
assert resolve_source_name(pkg, "apt") == "fd-find"
@@ -117,7 +115,7 @@ class TestResolveSourceName:
name="fd", type="pkg", sources={},
source=None, version=None, asset_pattern=None,
platform_map={}, extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)
assert resolve_source_name(pkg, "apt") == "fd"
@@ -131,7 +129,7 @@ class TestResolveBinaryAsset:
asset_pattern=None,
platform_map={"linux-x64": "nvim-linux-x86_64.tar.gz"},
extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)
assert resolve_binary_asset(pkg, "linux-x64") == "nvim-linux-x86_64.tar.gz"
@@ -143,7 +141,7 @@ class TestResolveBinaryAsset:
asset_pattern="fd-v10.2.0-{{arch}}-unknown-{{os}}-gnu.tar.gz",
platform_map={},
extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)
result = resolve_binary_asset(pkg, "linux-x64")
assert "x64" in result
@@ -157,7 +155,7 @@ class TestResolveBinaryAsset:
asset_pattern="nvim-{{os}}-{{arch}}.tar.gz",
platform_map={"linux-x64": {"os": "linux", "arch": "x86_64"}},
extract_dir="nvim-{{os}}64", install={},
post_install=None, allow_sudo=False,
post_install=None,
)
assert resolve_binary_asset(pkg, "linux-x64") == "nvim-linux-x86_64.tar.gz"
assert resolve_extract_dir(pkg, "linux-x64") == "nvim-linux64"
@@ -171,7 +169,7 @@ class TestResolveDownloadUrl:
version="v0.10.4",
asset_pattern=None, platform_map={},
extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)
url = resolve_download_url(pkg, "nvim.tar.gz")
assert "github.com/neovim/neovim" in url
@@ -184,7 +182,7 @@ class TestResolveDownloadUrl:
version="0.10.4",
asset_pattern=None, platform_map={},
extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)
url = resolve_download_url(pkg, "nvim.tar.gz", "linux-x64")
assert "/download/v0.10.4/" in url
@@ -196,7 +194,7 @@ class TestResolveDownloadUrl:
version=None,
asset_pattern=None, platform_map={},
extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)
url = resolve_download_url(pkg, "nvim.tar.gz")
assert "latest" in url
@@ -208,7 +206,7 @@ class TestResolveDownloadUrl:
version=None,
asset_pattern=None, platform_map={},
extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)
url = resolve_download_url(pkg, "x.tar.gz")
assert url == "https://example.com/download/x.tar.gz"
@@ -247,7 +245,7 @@ class TestPlanning:
name="wezterm", type="cask", sources={"brew": "wezterm"},
source=None, version=None, asset_pattern=None,
platform_map={}, extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)
plan = plan_install([pkg], InstalledState(), "macos-arm64", "brew")
assert plan.install_ops[0].method == "cask"

View File

@@ -32,12 +32,36 @@ def test_installed_state_version_mismatch():
InstalledState.from_dict({"version": 99, "packages": {}})
def test_installed_state_corrupt_missing_version_raises():
with pytest.raises(ConfigError, match="missing field 'version'"):
InstalledState.from_dict({
"version": 1,
"packages": {"fd": {"type": "pkg", "files": []}},
})
def test_installed_state_corrupt_missing_type_raises():
with pytest.raises(ConfigError, match="missing field 'type'"):
InstalledState.from_dict({
"version": 1,
"packages": {"fd": {"version": "1.0", "files": []}},
})
def test_installed_state_corrupt_missing_files_raises():
with pytest.raises(ConfigError, match="missing field 'files'"):
InstalledState.from_dict({
"version": 1,
"packages": {"fd": {"version": "1.0", "type": "pkg"}},
})
def test_package_def_fields():
pkg = PackageDef(
name="fd", type="pkg", sources={"apt": "fd-find"},
source=None, version=None, asset_pattern=None,
platform_map={}, extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)
assert pkg.name == "fd"
assert pkg.type == "pkg"

View File

@@ -1,7 +1,11 @@
"""Tests for package install/remove planning."""
import inspect
from pathlib import Path
import pytest
from flow.core.errors import FlowError
from flow.domain.packages.models import (
InstalledPackage,
InstalledState,
@@ -16,7 +20,7 @@ def _pkg(name, type="pkg", sources=None, source=None, version=None,
name=name, type=type, sources=sources or {},
source=source, version=version, asset_pattern=asset_pattern,
platform_map=platform_map or {}, extract_dir=None, install={},
post_install=None, allow_sudo=False,
post_install=None,
)
@@ -62,3 +66,15 @@ class TestPlanRemove:
def test_remove_not_installed(self):
plan = plan_remove(["missing"], InstalledState())
assert len(plan.remove_ops) == 0
class TestPlanInstallSignature:
def test_pm_is_required_positional(self):
sig = inspect.signature(plan_install)
param = sig.parameters["pm"]
assert param.default is inspect.Parameter.empty
def test_pkg_without_pm_raises(self):
pkgs = [_pkg("fd", sources={"apt": "fd-find"})]
with pytest.raises(FlowError, match="No supported package manager"):
plan_install(pkgs, InstalledState(), "linux-x64", None)

View File

@@ -10,7 +10,6 @@ from flow.domain.remote.resolution import (
list_targets,
parse_target,
resolve_target,
terminfo_fix_command,
)
@@ -84,9 +83,3 @@ class TestListTargets:
targets = list_targets(configs)
assert len(targets) == 2
assert targets[0].label == "a@b"
class TestTerminfoFix:
def test_returns_command(self):
cmd = terminfo_fix_command()
assert "infocmp" in cmd

View File

@@ -83,7 +83,6 @@ class TestBootstrapService:
"os": "linux",
"packages": [{
"name": "docker",
"allow-sudo": True,
"post-install": "sudo groupadd docker || true",
}],
},
@@ -93,9 +92,27 @@ class TestBootstrapService:
ctx = _make_ctx(manifest)
BootstrapService(ctx).run("linux-auto")
assert captured["packages"][0].allow_sudo is True
assert captured["packages"][0].post_install == "sudo groupadd docker || true"
def test_unknown_phase_raises(self):
from flow.domain.bootstrap.models import BootstrapAction, BootstrapPlan
from flow.domain.bootstrap.models import VALID_PHASES
manifest = {"profiles": {"work": {"os": "linux"}}}
ctx = _make_ctx(manifest)
svc = BootstrapService(ctx)
# Forge an action with a phase that VALID_PHASES contains but the
# dispatch can't handle (shouldn't happen, but tests the explicit guard).
# Use a phase NOT in VALID_PHASES first to confirm the "Unknown" branch.
action = BootstrapAction.__new__(BootstrapAction)
object.__setattr__(action, "phase", "no-such-phase")
object.__setattr__(action, "description", "")
object.__setattr__(action, "commands", ())
object.__setattr__(action, "needs_sudo", False)
plan = BootstrapPlan(profile="work", actions=(), packages_to_install=())
with pytest.raises(FlowError, match="Unknown bootstrap phase"):
svc._execute_action(action, plan, "work")
def test_run_uses_dotfiles_profile_override(self, monkeypatch):
captured = {}

View File

@@ -2,6 +2,7 @@
import io
import tarfile
import urllib.error
from pathlib import Path
import pytest
@@ -12,7 +13,7 @@ from flow.core.errors import FlowError
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
from flow.core import paths
from flow.domain.packages.models import InstalledPackage, InstalledState
from flow.domain.packages.models import InstalledPackage, InstalledState, PackageDef
from flow.services.packages import PackageService
@@ -78,6 +79,7 @@ class TestPackageService:
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HOME", str(home))
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data")
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
@@ -130,3 +132,105 @@ class TestPackageService:
assert (home / ".local" / "bin" / "nvim").exists()
assert (home / ".local" / "share" / "nvim" / "runtime.txt").exists()
assert (home / ".local" / "share" / "man" / "man1" / "nvim.1").exists()
def test_post_install_with_sudo_runs_unchecked(self, tmp_path, monkeypatch):
"""No allow_sudo gate -- post-install scripts run as written."""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
calls: list[str] = []
class _Runner:
def run_shell(self, command, **kwargs):
calls.append(command)
class _Result:
returncode = 0
stdout = ""
stderr = ""
return _Result()
ctx = _make_ctx(tmp_path)
ctx.runtime.runner = _Runner()
svc = PackageService(ctx)
pkg = PackageDef(
name="docker", type="pkg", sources={},
source=None, version=None, asset_pattern=None,
platform_map={}, extract_dir=None, install={},
post_install="sudo groupadd docker || true",
)
svc._run_post_install(pkg)
assert calls == ["sudo groupadd docker || true"]
def test_install_binary_url_failure_raises_flow_error(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data")
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
def _raise(*args, **kwargs):
raise urllib.error.URLError("Network unreachable")
monkeypatch.setattr(
"flow.services.packages.urllib.request.urlopen", _raise,
)
manifest = {
"packages": [{
"name": "neovim",
"type": "binary",
"source": "github:neovim/neovim",
"version": "0.10.4",
"platform-map": {"linux-x64": "nvim-linux-x64.tar.gz"},
"install": {"bin": ["bin/nvim"]},
}],
}
ctx = _make_ctx(tmp_path, manifest)
svc = PackageService(ctx)
packages = svc.resolve_install_packages(package_names=["neovim"])
with pytest.raises(FlowError, match="Failed to download"):
svc.install(packages)
def test_install_path_absolute_raises(self, tmp_path, monkeypatch):
monkeypatch.setattr(paths, "HOME", tmp_path / "home")
ctx = _make_ctx(tmp_path)
svc = PackageService(ctx)
with pytest.raises(FlowError, match="must be relative"):
svc._validate_install_path("pkg", Path("/etc/passwd"))
def test_install_path_parent_traversal_raises(self, tmp_path, monkeypatch):
monkeypatch.setattr(paths, "HOME", tmp_path / "home")
ctx = _make_ctx(tmp_path)
svc = PackageService(ctx)
with pytest.raises(FlowError, match="parent traversal"):
svc._validate_install_path("pkg", Path("../etc/passwd"))
def test_install_path_escapes_extract_dir_raises(self, tmp_path, monkeypatch):
"""A relative path whose resolved location is outside the extract dir."""
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(paths, "HOME", home)
ctx = _make_ctx(tmp_path)
svc = PackageService(ctx)
extract_root = tmp_path / "extract"
extract_root.mkdir()
sibling = tmp_path / "sibling"
sibling.mkdir()
# Symlink inside the extract root pointing outside -- the resolved
# source escapes the root.
link = extract_root / "evil"
link.symlink_to(sibling)
with pytest.raises(FlowError, match="escapes extract-dir"):
svc._copy_install_item(
"pkg",
extract_root,
extract_root.resolve(),
"bin",
"evil/escape",
)

View File

@@ -55,10 +55,3 @@ class TestRemoteService:
svc = RemoteService(ctx)
svc.list()
assert "No targets" in capsys.readouterr().out
def test_fix_terminfo(self, capsys):
ctx = _make_ctx()
svc = RemoteService(ctx)
svc.fix_terminfo("personal@orb")
output = capsys.readouterr().out
assert "infocmp" in output

View File

@@ -2,6 +2,9 @@
import os
import pytest
from flow.core.errors import ConfigError
from flow.core.template import substitute, substitute_template
@@ -36,8 +39,18 @@ class TestSubstituteTemplate:
ctx = {"platform": {"arch": "arm64"}}
assert substitute_template("{{ platform.arch }}", ctx) == "arm64"
def test_preserves_unknown_templates(self):
assert substitute_template("{{ unknown }}", {}) == "{{ unknown }}"
def test_unknown_variable_raises(self):
with pytest.raises(ConfigError, match=r"\{\{ unknown \}\}"):
substitute_template("{{ unknown }}", {})
def test_nested_unresolved_raises(self):
with pytest.raises(ConfigError, match=r"\{\{ platform.missing \}\}"):
substitute_template("{{ platform.missing }}", {"platform": {"arch": "arm64"}})
def test_unresolved_env_raises(self, monkeypatch):
monkeypatch.delenv("SOME_NEVER_SET_VAR", raising=False)
with pytest.raises(ConfigError):
substitute_template("{{ env.SOME_NEVER_SET_VAR }}", {"env": {}})
def test_non_string_passthrough(self):
assert substitute_template(42, {}) == 42