Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions custom_components/eybond_local/metadata/local_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,11 +270,7 @@ def _ensure_can_write(path: Path, root: Path, *, overwrite: bool) -> None:


def _is_within_root(path: Path, root: Path) -> bool:
try:
path.relative_to(root.resolve())
except ValueError:
return False
return True
return path.resolve().is_relative_to(root.resolve())


def _local_override_details(
Expand Down Expand Up @@ -303,7 +299,11 @@ def _local_override_details(
return {
"exists": False,
"path": str(path),
"status": f"No active local override. Create {path.relative_to(root.parent)} to override the built-in {kind}.",
"status": (
"No active local override. Create "
f"{path.resolve().relative_to(root.parent.resolve())} "
f"to override the built-in {kind}."
),
}


Expand Down
6 changes: 1 addition & 5 deletions custom_components/eybond_local/metadata/profile_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,11 +536,7 @@ def _optional_str(value: Any) -> str | None:


def _is_within_root(path: Path, root: Path) -> bool:
try:
path.relative_to(root.resolve())
except ValueError:
return False
return True
return path.resolve().is_relative_to(root.resolve())


def _merge_raw_profile(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,11 +419,7 @@ def _optional_str(value: Any) -> str | None:


def _is_within_root(path: Path, root: Path) -> bool:
try:
path.relative_to(root.resolve())
except ValueError:
return False
return True
return path.resolve().is_relative_to(root.resolve())


def _merge_raw_schema(
Expand Down
59 changes: 59 additions & 0 deletions tests/test_local_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@


from custom_components.eybond_local.metadata.local_metadata import (
_is_within_root as local_metadata_is_within_root,
clear_local_metadata_loader_caches,
create_local_profile_draft,
create_local_schema_draft,
Expand All @@ -24,6 +25,12 @@
resolve_local_metadata_rollback_paths,
rollback_local_metadata_overrides,
)
from custom_components.eybond_local.metadata.profile_loader import (
_is_within_root as profile_loader_is_within_root,
)
from custom_components.eybond_local.metadata.register_schema_loader import (
_is_within_root as register_schema_loader_is_within_root,
)
from custom_components.eybond_local.metadata.smartess_draft import (
create_smartess_known_family_draft,
resolve_smartess_known_family_draft_plan,
Expand Down Expand Up @@ -153,6 +160,58 @@ def _find_item(raw_items: list[dict[str, object]], key: str) -> dict[str, object


class LocalMetadataTests(unittest.TestCase):
def test_is_within_root_accepts_symlink_alias_forms(self) -> None:
"""Containment must survive mixed symlink forms (e.g. /var vs /private/var)."""
helpers = (
local_metadata_is_within_root,
profile_loader_is_within_root,
register_schema_loader_is_within_root,
)
with tempfile.TemporaryDirectory() as temp_dir:
base = Path(temp_dir)
real_root = base / "real"
real_root.mkdir()
alias_root = base / "alias"
alias_root.symlink_to(real_root.resolve())
child = real_root / "nested" / "item.json"
child.parent.mkdir(parents=True)
child.write_text("{}", encoding="utf-8")
alias_child = alias_root / "nested" / "item.json"
outside = base / "outside.json"
outside.write_text("{}", encoding="utf-8")

for helper in helpers:
with self.subTest(helper=helper.__module__):
self.assertTrue(helper(child, real_root))
self.assertTrue(helper(child, alias_root))
self.assertTrue(helper(alias_child, real_root))
self.assertTrue(helper(alias_child, alias_root))
# Mixed: unresolved alias path vs resolved real root (and reverse).
self.assertTrue(helper(alias_child, real_root.resolve()))
self.assertTrue(helper(child.resolve(), alias_root))
self.assertFalse(helper(outside, real_root))
self.assertFalse(helper(outside, alias_root))

def test_is_within_root_rejects_symlink_escape(self) -> None:
"""In-root symlink to a path outside root must not count as contained."""
helpers = (
local_metadata_is_within_root,
profile_loader_is_within_root,
register_schema_loader_is_within_root,
)
with tempfile.TemporaryDirectory() as temp_dir:
base = Path(temp_dir).resolve()
root = base / "root"
root.mkdir()
secret = base / "secret"
secret.write_text("{}", encoding="utf-8")
link_out = root / "link_out"
link_out.symlink_to(Path("..") / "secret")

for helper in helpers:
with self.subTest(helper=helper.__module__):
self.assertFalse(helper(link_out, root))

def test_detects_when_one_draft_name_overrides_builtin_metadata(self) -> None:
self.assertTrue(draft_activates_automatically("smg_modbus.json", None))
self.assertTrue(draft_activates_automatically("smg_modbus.json", "smg_modbus.json"))
Expand Down