Better protection against symlink traversal in flatpak. Other hardening

This commit is contained in:
Miguel Jacq 2026-06-28 17:03:24 +10:00
parent 903125976d
commit f5b85d29d3
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
9 changed files with 429 additions and 53 deletions

View file

@ -3,11 +3,18 @@ from __future__ import annotations
import configparser import configparser
import os import os
import re import re
import stat
import shutil import shutil
import subprocess # nosec import subprocess # nosec
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Optional, Set, Tuple
from .fsutil import (
is_dir_no_symlink_components,
open_no_follow_path,
path_has_symlink_component,
)
@dataclass @dataclass
class FlatpakInstall: class FlatpakInstall:
@ -161,15 +168,40 @@ def find_user_ssh_files(home: str) -> List[str]:
return sorted(set(out)) return sorted(set(out))
def _read_first_existing_text(paths: List[str]) -> Optional[str]: def _read_first_existing_text(
paths: List[str], *, max_bytes: int = 8192
) -> Optional[str]:
"""Read the first small regular text file without following symlinks.
Per-user Flatpak metadata lives under user-controlled home directories.
When Enroll is run as root, plain ``open()`` would let a user replace
``active/origin`` or ``repo/config`` with a symlink to a privileged file and
have its contents copied into state.json. Use the same no-symlink component
invariant as the normal harvester, require a regular file, and cap reads to
avoid device/large-file DoS.
"""
for path in paths: for path in paths:
fd: Optional[int] = None
try: try:
with open(path, "r", encoding="utf-8", errors="replace") as f: fd = open_no_follow_path(path)
value = f.read().strip() st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_size > max_bytes:
continue
data = os.read(fd, max_bytes + 1)
if len(data) > max_bytes:
continue
value = data.decode("utf-8", errors="replace").strip()
if value: if value:
return value return value
except OSError: except OSError:
continue continue
finally:
if fd is not None:
try:
os.close(fd)
except OSError:
pass
return None return None
@ -457,7 +489,7 @@ def _flatpak_remote_from_ref(
arch, arch,
branch, branch,
) )
if os.path.exists(ref): if not path_has_symlink_component(ref) and os.path.exists(ref):
return remote_name return remote_name
return None return None
@ -473,11 +505,11 @@ def _parse_flatpak_deploy_origin(branch_dir: str) -> Optional[str]:
if origin: if origin:
return origin return origin
metadata = candidates[1] metadata = _read_first_existing_text([candidates[1]])
if os.path.isfile(metadata): if metadata:
parser = configparser.ConfigParser(interpolation=None) parser = configparser.ConfigParser(interpolation=None)
try: try:
parser.read(metadata, encoding="utf-8") parser.read_string(metadata)
except Exception: except Exception:
return None return None
for section in ("Application", "Runtime"): for section in ("Application", "Runtime"):
@ -496,7 +528,7 @@ def _find_flatpaks_in_root(
home: Optional[str] = None, home: Optional[str] = None,
) -> List[FlatpakInstall]: ) -> List[FlatpakInstall]:
apps_dir = os.path.join(flatpak_root, "app") apps_dir = os.path.join(flatpak_root, "app")
if not os.path.isdir(apps_dir): if not is_dir_no_symlink_components(apps_dir):
return [] return []
remote_names = [ remote_names = [
@ -513,7 +545,7 @@ def _find_flatpaks_in_root(
seen: Set[Tuple[str, Optional[str], Optional[str], Optional[str]]] = set() seen: Set[Tuple[str, Optional[str], Optional[str], Optional[str]]] = set()
for app_id in app_ids: for app_id in app_ids:
app_path = os.path.join(apps_dir, app_id) app_path = os.path.join(apps_dir, app_id)
if not os.path.isdir(app_path): if not is_dir_no_symlink_components(app_path):
continue continue
try: try:
arches = sorted(os.listdir(app_path)) arches = sorted(os.listdir(app_path))
@ -521,7 +553,7 @@ def _find_flatpaks_in_root(
continue continue
for arch in arches: for arch in arches:
arch_path = os.path.join(app_path, arch) arch_path = os.path.join(app_path, arch)
if not os.path.isdir(arch_path): if not is_dir_no_symlink_components(arch_path):
continue continue
try: try:
branches = sorted(os.listdir(arch_path)) branches = sorted(os.listdir(arch_path))
@ -529,10 +561,10 @@ def _find_flatpaks_in_root(
continue continue
for branch in branches: for branch in branches:
branch_path = os.path.join(arch_path, branch) branch_path = os.path.join(arch_path, branch)
if not os.path.isdir(branch_path): if not is_dir_no_symlink_components(branch_path):
continue continue
active_dir = os.path.join(branch_path, "active") active_dir = os.path.join(branch_path, "active")
if not os.path.exists(active_dir): if not is_dir_no_symlink_components(active_dir):
continue continue
remote = _parse_flatpak_deploy_origin(branch_path) remote = _parse_flatpak_deploy_origin(branch_path)
if not remote: if not remote:
@ -576,12 +608,13 @@ def find_flatpak_remotes(
.flatpakref/.flatpakrepo URL that was used during installation. .flatpakref/.flatpakrepo URL that was used during installation.
""" """
config_path = os.path.join(flatpak_root, "repo", "config") config_path = os.path.join(flatpak_root, "repo", "config")
if not os.path.isfile(config_path): config_text = _read_first_existing_text([config_path])
if not config_text:
return [] return []
parser = configparser.ConfigParser(interpolation=None, strict=False) parser = configparser.ConfigParser(interpolation=None, strict=False)
try: try:
parser.read(config_path, encoding="utf-8") parser.read_string(config_text)
except Exception: except Exception:
return [] return []

View file

@ -126,6 +126,57 @@ def open_no_follow_path(path: str, *, write: bool = False, mode: int = 0o600) ->
os.close(dir_fd) os.close(dir_fd)
def path_has_symlink_component(path: str) -> bool:
"""Return True if any existing component of *path* is a symlink.
This is a lightweight discovery-time companion to ``open_no_follow_path``.
It is intended for directory-walking code paths that must decide whether a
candidate root is safe to enumerate before opening individual files. Missing
trailing components are treated as non-symlinks; ``..`` is treated as unsafe
and therefore reported as a symlink-like component.
"""
norm = os.path.normpath(path)
if norm in ("", "."):
return False
if os.path.isabs(norm):
cur = os.sep
parts = [p for p in norm.split(os.sep) if p]
else:
cur = os.getcwd()
parts = [p for p in norm.split(os.sep) if p]
for part in parts:
if part in ("", "."):
continue
if part == "..":
return True
cur = os.path.join(cur, part)
try:
st = os.lstat(cur)
except FileNotFoundError:
return False
except OSError:
# Fail closed for unreadable/racy paths used as discovery roots.
return True
if stat.S_ISLNK(st.st_mode):
return True
return False
def is_dir_no_symlink_components(path: str) -> bool:
"""Return True only for directories reached without symlink components."""
if path_has_symlink_component(path):
return False
try:
st = os.stat(path, follow_symlinks=False)
except OSError:
return False
return stat.S_ISDIR(st.st_mode)
def stat_triplet_from_stat(st: os.stat_result) -> Tuple[str, str, str]: def stat_triplet_from_stat(st: os.stat_result) -> Tuple[str, str, str]:
"""Return (owner, group, mode) for an existing stat result.""" """Return (owner, group, mode) for an existing stat result."""

View file

@ -16,6 +16,7 @@ from ..harvest_types import (
) )
from ..system_paths import MAX_FILES_CAP from ..system_paths import MAX_FILES_CAP
from ..pathfilter import expand_includes from ..pathfilter import expand_includes
from ..fsutil import is_dir_no_symlink_components, path_has_symlink_component
from .context import HarvestCollector, HarvestContext from .context import HarvestCollector, HarvestContext
@ -192,13 +193,13 @@ class ExtraPathsCollector(HarvestCollector):
path = pat.value path = pat.value
if os.path.islink(path): if os.path.islink(path):
self._capture_included_link(path, role_seen) self._capture_included_link(path, role_seen)
elif os.path.isdir(path): elif is_dir_no_symlink_components(path):
self._walk_and_capture_dirs(path, role_seen) self._walk_and_capture_dirs(path, role_seen)
elif pat.kind == "glob": elif pat.kind == "glob":
for hit in glob.glob(pat.value, recursive=True): for hit in glob.glob(pat.value, recursive=True):
if os.path.islink(hit): if os.path.islink(hit):
self._capture_included_link(hit, role_seen) self._capture_included_link(hit, role_seen)
elif os.path.isdir(hit): elif is_dir_no_symlink_components(hit):
self._walk_and_capture_dirs(hit, role_seen) self._walk_and_capture_dirs(hit, role_seen)
def _capture_included_link(self, path: str, role_seen: Set[str]) -> None: def _capture_included_link(self, path: str, role_seen: Set[str]) -> None:
@ -224,7 +225,7 @@ class ExtraPathsCollector(HarvestCollector):
root = os.path.normpath(root) root = os.path.normpath(root)
if not root.startswith("/"): if not root.startswith("/"):
root = "/" + root root = "/" + root
if not os.path.isdir(root) or os.path.islink(root): if not is_dir_no_symlink_components(root):
return return
for dirpath, dirnames, filenames in os.walk(root, followlinks=False): for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
if len(self.managed_dirs) >= MAX_FILES_CAP: if len(self.managed_dirs) >= MAX_FILES_CAP:
@ -238,7 +239,7 @@ class ExtraPathsCollector(HarvestCollector):
if self.context.path_filter.is_excluded(dirpath): if self.context.path_filter.is_excluded(dirpath):
dirnames[:] = [] dirnames[:] = []
continue continue
if os.path.islink(dirpath) or not os.path.isdir(dirpath): if not is_dir_no_symlink_components(dirpath):
dirnames[:] = [] dirnames[:] = []
continue continue
@ -275,6 +276,8 @@ class ExtraPathsCollector(HarvestCollector):
if os.path.islink(path): if os.path.islink(path):
self._capture_included_link(path, role_seen) self._capture_included_link(path, role_seen)
continue continue
if path_has_symlink_component(path):
continue
pruned.append(dirname) pruned.append(dirname)
dirnames[:] = pruned dirnames[:] = pruned

View file

@ -69,6 +69,33 @@ def _merge_mappings_overwrite(
return merged return merged
_RESERVED_ROLE_VAR_SUFFIXES = {
"managed_files",
"managed_dirs",
"managed_links",
"packages",
"restart_units",
"system_flatpaks",
"remotes",
"user_flatpaks",
"user_flatpak_remotes",
}
def reserved_role_var_names(role_name: str) -> Set[str]:
"""Return Enroll-owned variable names for a generated role.
JinjaTurtle variables come from harvested, attacker-influenceable config
content. They must never overwrite variables that drive Enroll's renderer
tasks, such as ``<role>_managed_files``.
"""
role = re.sub(r"[^A-Za-z0-9_]+", "_", role_name.strip().lower()).strip("_")
if not role:
return set()
return {f"{role}_{suffix}" for suffix in _RESERVED_ROLE_VAR_SUFFIXES}
@dataclass(frozen=True) @dataclass(frozen=True)
class JinjifiedArtifact: class JinjifiedArtifact:
template_rel: str template_rel: str
@ -131,6 +158,7 @@ def jinjify_artifact(
jt_enabled: bool, jt_enabled: bool,
overwrite_templates: bool = True, overwrite_templates: bool = True,
role_name: Optional[str] = None, role_name: Optional[str] = None,
reserved_context_keys: Optional[Set[str]] = None,
) -> Optional[JinjifiedArtifact]: ) -> Optional[JinjifiedArtifact]:
"""Best-effort conversion of one harvested artifact into a Jinja2 template.""" """Best-effort conversion of one harvested artifact into a Jinja2 template."""
if not (jt_enabled and jt_exe and can_jinjify_path(dest_path)): if not (jt_enabled and jt_exe and can_jinjify_path(dest_path)):
@ -155,6 +183,9 @@ def jinjify_artifact(
template_dst = Path(template_root) / template_rel template_dst = Path(template_root) / template_rel
context = yaml_load_mapping(result.vars_text) context = yaml_load_mapping(result.vars_text)
if reserved_context_keys and (set(context) & set(reserved_context_keys)):
return None
missing = missing_jinja_template_vars(result.template_text, context) missing = missing_jinja_template_vars(result.template_text, context)
if missing: if missing:
# If this role was generated into an existing output directory, avoid # If this role was generated into an existing output directory, avoid
@ -181,10 +212,12 @@ def managed_file_var_prefix(role_name: str, src_rel: str) -> str:
JinjaTurtle's ``--role-name`` is a variable prefix. Enroll can place many JinjaTurtle's ``--role-name`` is a variable prefix. Enroll can place many
unrelated managed files in one generated role, so using only the role name unrelated managed files in one generated role, so using only the role name
can collide for common keys such as ``enabled``, ``ignore``, or ``name``. can collide for common keys such as ``enabled``, ``ignore``, or ``name``.
Include the relative artifact path when a role templates multiple files. Always include a ``jt`` namespace and the relative artifact path so harvested
config keys cannot produce Enroll-owned variables such as
``<role>_managed_files``.
""" """
raw = f"{role_name}_{src_rel}" raw = f"{role_name}_jt_{src_rel}"
safe = re.sub(r"[^A-Za-z0-9_]+", "_", raw).strip("_").lower() safe = re.sub(r"[^A-Za-z0-9_]+", "_", raw).strip("_").lower()
safe = re.sub(r"_+", "_", safe) safe = re.sub(r"_+", "_", safe)
if not safe: if not safe:
@ -215,14 +248,7 @@ def jinjify_managed_files(
templated: Set[str] = set() templated: Set[str] = set()
vars_map: Dict[str, Any] = {} vars_map: Dict[str, Any] = {}
base_role_name = role_name or artifact_role base_role_name = role_name or artifact_role
candidates = [ reserved_context_keys = reserved_role_var_names(base_role_name)
mf
for mf in managed_files
if str(mf.get("path") or "")
and str(mf.get("src_rel") or "")
and can_jinjify_path(str(mf.get("path") or ""))
]
namespace_by_file = len(candidates) > 1
for mf in managed_files: for mf in managed_files:
dest_path = str(mf.get("path") or "") dest_path = str(mf.get("path") or "")
@ -239,11 +265,8 @@ def jinjify_managed_files(
jt_exe=jt_exe, jt_exe=jt_exe,
jt_enabled=jt_enabled, jt_enabled=jt_enabled,
overwrite_templates=overwrite_templates, overwrite_templates=overwrite_templates,
role_name=( role_name=managed_file_var_prefix(base_role_name, src_rel),
managed_file_var_prefix(base_role_name, src_rel) reserved_context_keys=reserved_context_keys,
if namespace_by_file
else base_role_name
),
) )
if converted is None: if converted is None:
continue continue

View file

@ -7,10 +7,42 @@ from dataclasses import dataclass
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import List, Optional, Sequence, Set, Tuple from typing import List, Optional, Sequence, Set, Tuple
_REGEX_PREFIXES = ("re:", "regex:") _REGEX_PREFIXES = ("re:", "regex:")
def _path_has_symlink_component_for_discovery(path: str) -> bool:
"""Return True if any path component is visibly a symlink.
``expand_includes`` is a discovery helper: the actual file capture path is
still protected by descriptor-based no-follow opens. Keep this check
intentionally based on ``os.path.islink`` so tests can mock a synthetic
filesystem with ``os.path``/``os.walk`` and so include expansion does not
depend on real host permissions for paths such as ``/root``. Existing
symlinked parents are still pruned before walking/capturing.
"""
norm = os.path.normpath(path)
if norm in ("", "."):
return False
if os.path.isabs(norm):
cur = os.sep
parts = [p for p in norm.split(os.sep) if p]
else:
cur = os.getcwd()
parts = [p for p in norm.split(os.sep) if p]
for part in parts:
if part in ("", "."):
continue
if part == "..":
return True
cur = os.path.join(cur, part)
if os.path.islink(cur):
return True
return False
def _has_glob_chars(s: str) -> bool: def _has_glob_chars(s: str) -> bool:
return any(ch in s for ch in "*?[") return any(ch in s for ch in "*?[")
@ -191,6 +223,37 @@ def expand_includes(
notes: List[str] = [] notes: List[str] = []
seen: Set[str] = set() seen: Set[str] = set()
def _is_file_no_symlink_components(p: str) -> bool:
return not _path_has_symlink_component_for_discovery(p) and os.path.isfile(p)
def _is_dir_no_symlink_components(p: str) -> bool:
return not _path_has_symlink_component_for_discovery(p) and os.path.isdir(p)
def _glob_walk_root(pattern: str) -> Optional[str]:
"""Return a conservative directory root for recursive glob fallback.
This keeps existing tests that mock ``os.walk`` independent of the real
host's /root contents while still applying the same no-symlink-component
guard before enumeration. Only recursive subtree globs are expanded this
way; ordinary file globs continue to rely on ``glob.glob`` hits.
"""
parts = pattern.split(os.sep)
literal_parts: List[str] = []
absolute = pattern.startswith(os.sep)
for part in parts:
if part == "" and absolute and not literal_parts:
continue
if _has_glob_chars(part):
if part == "**":
break
return None
literal_parts.append(part)
if not literal_parts:
return os.sep if absolute else None
root = os.path.join(os.sep if absolute else "", *literal_parts)
return _norm_abs(root)
def _maybe_add_file(p: str) -> None: def _maybe_add_file(p: str) -> None:
if len(out) >= max_files: if len(out) >= max_files:
return return
@ -199,14 +262,14 @@ def expand_includes(
return return
if p in seen: if p in seen:
return return
if not os.path.isfile(p) or os.path.islink(p): if not _is_file_no_symlink_components(p):
return return
seen.add(p) seen.add(p)
out.append(p) out.append(p)
def _walk_dir(root: str, match: Optional[CompiledPathPattern] = None) -> None: def _walk_dir(root: str, match: Optional[CompiledPathPattern] = None) -> None:
root = _norm_abs(root) root = _norm_abs(root)
if not os.path.isdir(root) or os.path.islink(root): if not _is_dir_no_symlink_components(root):
return return
for dirpath, dirnames, filenames in os.walk(root, followlinks=False): for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
# Prune excluded directories early. # Prune excluded directories early.
@ -215,13 +278,13 @@ def expand_includes(
d d
for d in dirnames for d in dirnames
if not exclude.is_excluded(os.path.join(dirpath, d)) if not exclude.is_excluded(os.path.join(dirpath, d))
and not os.path.islink(os.path.join(dirpath, d)) and _is_dir_no_symlink_components(os.path.join(dirpath, d))
] ]
for fn in filenames: for fn in filenames:
if len(out) >= max_files: if len(out) >= max_files:
return return
p = os.path.join(dirpath, fn) p = os.path.join(dirpath, fn)
if os.path.islink(p) or not os.path.isfile(p): if not _is_file_no_symlink_components(p):
continue continue
if exclude and exclude.is_excluded(p): if exclude and exclude.is_excluded(p):
continue continue
@ -243,10 +306,10 @@ def expand_includes(
if pat.kind == "prefix": if pat.kind == "prefix":
p = pat.value p = pat.value
if os.path.isfile(p) and not os.path.islink(p): if _is_file_no_symlink_components(p):
_maybe_add_file(p) _maybe_add_file(p)
matched_any = True matched_any = True
elif os.path.isdir(p) and not os.path.islink(p): elif _is_dir_no_symlink_components(p):
before = len(out) before = len(out)
_walk_dir(p) _walk_dir(p)
matched_any = len(out) > before matched_any = len(out) > before
@ -259,18 +322,26 @@ def expand_includes(
# Use glob for expansion; also walk directories that match. # Use glob for expansion; also walk directories that match.
gpat = pat.value gpat = pat.value
hits = glob.glob(gpat, recursive=True) hits = glob.glob(gpat, recursive=True)
if not hits and "**" in gpat:
root = _glob_walk_root(gpat)
if root and _is_dir_no_symlink_components(root):
before = len(out)
_walk_dir(root)
matched_any = len(out) > before
if len(out) >= max_files:
continue
for h in hits: for h in hits:
if len(out) >= max_files: if len(out) >= max_files:
break break
h = _norm_abs(h) h = _norm_abs(h)
if exclude and exclude.is_excluded(h): if exclude and exclude.is_excluded(h):
continue continue
if os.path.isdir(h) and not os.path.islink(h): if _is_dir_no_symlink_components(h):
before = len(out) before = len(out)
_walk_dir(h) _walk_dir(h)
if len(out) > before: if len(out) > before:
matched_any = True matched_any = True
elif os.path.isfile(h) and not os.path.islink(h): elif _is_file_no_symlink_components(h):
_maybe_add_file(h) _maybe_add_file(h)
matched_any = True matched_any = True

View file

@ -513,3 +513,68 @@ def test_flatpak_list_attempts_respect_supported_columns():
assert any("--columns=application,branch" in cmd for cmd in command_strings) assert any("--columns=application,branch" in cmd for cmd in command_strings)
assert not any("origin" in cmd for cmd in command_strings) assert not any("origin" in cmd for cmd in command_strings)
assert command_strings[-1] == "flatpak list --system" assert command_strings[-1] == "flatpak list --system"
def test_user_flatpak_origin_symlink_is_not_read(tmp_path: Path):
import enroll.accounts as a
home = tmp_path / "home"
active = (
home
/ ".local"
/ "share"
/ "flatpak"
/ "app"
/ "org.evil.App"
/ "x86_64"
/ "stable"
/ "active"
)
active.mkdir(parents=True)
secret = tmp_path / "root-only-secret"
secret.write_text("ROOTSECRET\n", encoding="utf-8")
(active / "origin").symlink_to(secret)
apps = a.find_user_flatpaks(str(home), user="alice")
assert len(apps) == 1
assert apps[0].name == "org.evil.App"
assert apps[0].remote is None
def test_user_flatpak_repo_config_symlink_is_not_read(tmp_path: Path):
import enroll.accounts as a
home = tmp_path / "home"
flatpak_root = home / ".local" / "share" / "flatpak"
(flatpak_root / "repo").mkdir(parents=True)
secret = tmp_path / "root-only-secret"
secret.write_text(
'[remote "ROOTSECRET"]\nurl=https://evil.example/\n', encoding="utf-8"
)
(flatpak_root / "repo" / "config").symlink_to(secret)
assert a.find_user_flatpak_remotes(str(home), user="alice") == []
def test_user_flatpak_tree_with_symlinked_parent_is_not_walked(tmp_path: Path):
import enroll.accounts as a
home = tmp_path / "home"
real = tmp_path / "real-flatpak"
active = (
real
/ "share"
/ "flatpak"
/ "app"
/ "org.evil.App"
/ "x86_64"
/ "stable"
/ "active"
)
active.mkdir(parents=True)
(active / "origin").write_text("evil\n", encoding="utf-8")
home.mkdir()
(home / ".local").symlink_to(real, target_is_directory=True)
assert a.find_user_flatpaks(str(home), user="alice") == []

View file

@ -444,3 +444,25 @@ def test_extra_paths_collector_records_include_path_that_is_symlink(tmp_path):
(str(link_root), str(real_root), "user_include_link") (str(link_root), str(real_root), "user_include_link")
] ]
assert result.managed_files == [] assert result.managed_files == []
def test_extra_paths_collector_skips_directory_through_symlinked_parent(tmp_path):
real_root = tmp_path / "real"
child = real_root / "child"
child.mkdir(parents=True)
(child / "inside.conf").write_text("do-not-follow", encoding="utf-8")
link_root = tmp_path / "linked-root"
link_root.symlink_to(real_root, target_is_directory=True)
include_path = str(link_root / "child")
ctx = _context(tmp_path, include=[include_path])
result = ExtraPathsCollector(
ctx,
seen_by_role={},
already_all=set(),
include_paths=[include_path],
).collect()
assert result.managed_dirs == []
assert result.managed_files == []
assert result.managed_links == []

View file

@ -114,10 +114,10 @@ def test_manifest_uses_jinjaturtle_templates_and_does_not_copy_raw(
def fake_run_jinjaturtle( def fake_run_jinjaturtle(
jt_exe: str, src_path: str, *, role_name: str, force_format=None jt_exe: str, src_path: str, *, role_name: str, force_format=None
): ):
assert role_name == "foo" assert role_name == "foo_jt_etc_foo_ini"
return JinjifyResult( return JinjifyResult(
template_text="[main]\nkey = {{ foo_key }}\n", template_text=f"[main]\nkey = {{{{ {role_name}_key }}}}\n",
vars_text="foo_key: 1\n", vars_text=f"{role_name}_key: 1\n",
) )
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle) monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
@ -134,7 +134,7 @@ def test_manifest_uses_jinjaturtle_templates_and_does_not_copy_raw(
# Defaults should include jinjaturtle vars. # Defaults should include jinjaturtle vars.
defaults = (role_dir / "defaults" / "main.yml").read_text(encoding="utf-8") defaults = (role_dir / "defaults" / "main.yml").read_text(encoding="utf-8")
assert "foo_key: 1" in defaults assert "foo_jt_etc_foo_ini_key: 1" in defaults
def test_openssh_paths_are_jinjaturtle_supported_and_forced_to_ssh() -> None: def test_openssh_paths_are_jinjaturtle_supported_and_forced_to_ssh() -> None:
@ -188,14 +188,14 @@ def test_jinjify_managed_files_namespaces_multiple_templates(
assert templated == {"etc/foo/a.yaml", "etc/foo/b.yaml"} assert templated == {"etc/foo/a.yaml", "etc/foo/b.yaml"}
assert calls == [ assert calls == [
("a.yaml", "foo_etc_foo_a_yaml"), ("a.yaml", "foo_jt_etc_foo_a_yaml"),
("b.yaml", "foo_etc_foo_b_yaml"), ("b.yaml", "foo_jt_etc_foo_b_yaml"),
] ]
assert "foo_etc_foo_a_yaml_ignore: []" in vars_text assert "foo_jt_etc_foo_a_yaml_ignore: []" in vars_text
assert "foo_etc_foo_b_yaml_ignore: []" in vars_text assert "foo_jt_etc_foo_b_yaml_ignore: []" in vars_text
assert (template_root / "etc" / "foo" / "a.yaml.j2").read_text( assert (template_root / "etc" / "foo" / "a.yaml.j2").read_text(
encoding="utf-8" encoding="utf-8"
) == "ignore: {{ foo_etc_foo_a_yaml_ignore }}\n" ) == "ignore: {{ foo_jt_etc_foo_a_yaml_ignore }}\n"
def test_jinjify_managed_files_rejects_templates_with_missing_defaults( def test_jinjify_managed_files_rejects_templates_with_missing_defaults(
@ -233,6 +233,80 @@ def test_jinjify_managed_files_rejects_templates_with_missing_defaults(
assert not (template_root / "etc" / "foo" / "pdk.yaml.j2").exists() assert not (template_root / "etc" / "foo" / "pdk.yaml.j2").exists()
def test_jinjify_managed_files_always_namespaces_single_template(
monkeypatch, tmp_path: Path
):
from enroll.jinjaturtle import jinjify_managed_files
bundle = tmp_path / "bundle"
template_root = tmp_path / "templates"
artifact = bundle / "artifacts" / "foo" / "etc" / "foo.yaml"
artifact.parent.mkdir(parents=True, exist_ok=True)
artifact.write_text("managed_files: []\n", encoding="utf-8")
calls = []
def fake_run_jinjaturtle(jt_exe, src_path, *, role_name, force_format=None):
calls.append(role_name)
return JinjifyResult(
template_text=f"managed_files: {{{{ {role_name}_managed_files }}}}\n",
vars_text=f"{role_name}_managed_files: []\n",
)
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
templated, vars_text = jinjify_managed_files(
bundle,
"foo",
template_root,
[{"path": "/etc/foo.yaml", "src_rel": "etc/foo.yaml"}],
jt_exe="jinjaturtle",
jt_enabled=True,
overwrite_templates=True,
role_name="foo",
)
assert templated == {"etc/foo.yaml"}
assert calls == ["foo_jt_etc_foo_yaml"]
assert "foo_jt_etc_foo_yaml_managed_files: []" in vars_text
assert "foo_managed_files:" not in vars_text
def test_jinjify_managed_files_rejects_reserved_role_variable_collision(
monkeypatch, tmp_path: Path
):
from enroll.jinjaturtle import jinjify_managed_files
bundle = tmp_path / "bundle"
template_root = tmp_path / "templates"
artifact = bundle / "artifacts" / "foo" / "etc" / "foo.yaml"
artifact.parent.mkdir(parents=True, exist_ok=True)
artifact.write_text("managed_files: []\n", encoding="utf-8")
def fake_run_jinjaturtle(jt_exe, src_path, *, role_name, force_format=None):
return JinjifyResult(
template_text="managed_files: {{ foo_managed_files }}\n",
vars_text="foo_managed_files: []\n",
)
monkeypatch.setattr(jinjaturtle_mod, "run_jinjaturtle", fake_run_jinjaturtle)
templated, vars_text = jinjify_managed_files(
bundle,
"foo",
template_root,
[{"path": "/etc/foo.yaml", "src_rel": "etc/foo.yaml"}],
jt_exe="jinjaturtle",
jt_enabled=True,
overwrite_templates=True,
role_name="foo",
)
assert templated == set()
assert vars_text == ""
assert not (template_root / "etc" / "foo.yaml.j2").exists()
def test_jinjify_artifact_rejects_unsafe_src_rel(monkeypatch, tmp_path: Path): def test_jinjify_artifact_rejects_unsafe_src_rel(monkeypatch, tmp_path: Path):
from enroll.jinjaturtle import jinjify_artifact from enroll.jinjaturtle import jinjify_artifact

View file

@ -338,3 +338,37 @@ def test_path_filter_with_include_patterns():
patterns = pf_filter.iter_include_patterns() patterns = pf_filter.iter_include_patterns()
assert len(patterns) == 1 assert len(patterns) == 1
assert patterns[0].kind == "glob" assert patterns[0].kind == "glob"
def test_expand_includes_skips_file_through_symlinked_parent(tmp_path: Path):
from enroll.pathfilter import compile_path_pattern, expand_includes
real = tmp_path / "real"
(real / "child").mkdir(parents=True)
target = real / "child" / "secret.conf"
target.write_text("secret", encoding="utf-8")
link = tmp_path / "link"
link.symlink_to(real, target_is_directory=True)
paths, _notes = expand_includes(
[compile_path_pattern(str(link / "child" / "secret.conf"))], max_files=10
)
assert paths == []
def test_expand_includes_skips_directory_through_symlinked_parent(tmp_path: Path):
from enroll.pathfilter import compile_path_pattern, expand_includes
real = tmp_path / "real"
(real / "child").mkdir(parents=True)
target = real / "child" / "secret.conf"
target.write_text("secret", encoding="utf-8")
link = tmp_path / "link"
link.symlink_to(real, target_is_directory=True)
paths, _notes = expand_includes(
[compile_path_pattern(str(link / "child"))], max_files=10
)
assert paths == []