Better protection against symlink traversal in flatpak. Other hardening
This commit is contained in:
parent
903125976d
commit
f5b85d29d3
9 changed files with 429 additions and 53 deletions
|
|
@ -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 value:
|
if not stat.S_ISREG(st.st_mode) or st.st_size > max_bytes:
|
||||||
return value
|
continue
|
||||||
|
data = os.read(fd, max_bytes + 1)
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
continue
|
||||||
|
value = data.decode("utf-8", errors="replace").strip()
|
||||||
|
if 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 []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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") == []
|
||||||
|
|
|
||||||
|
|
@ -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 == []
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 == []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue