diff --git a/tests/README.md b/tests/README.md index 9b874f565..44b2db762 100644 --- a/tests/README.md +++ b/tests/README.md @@ -22,7 +22,7 @@ is adding a file. | `test_app_wrapper.sh` | Launching through either `app.sh` execution path does not leak an unrelated non-boolean `DEBUG` value into app2unit or xdg-terminal-exec | | `test_binds.sh` | Loads the Lua keybinds against a stubbed Hyprland API: no two binds share a combination once modifiers are folded to what Hyprland matches on, no keysym sits in a modifier position, no bind uses the `code:NN` form the Lua parser rejects, every bind has a description, every `hyde-shell` command it runs exists. Touchpad gestures are checked in the same pass: a valid finger count, a direction and action Hyprland accepts, and no two gestures on the same finger count and direction | | `test_git.sh` | The tree holds no gitlink without a matching `.gitmodules` entry, which would break `git submodule` and anything walking submodules | -| `test_dots.sh` | Every installer metafile under `Scripts/dots` parses, declares the keys the installer needs, uses a known action, and points at a source directory and source paths that exist. It also keeps Grimblast on the fixed official source | +| `test_dots.sh` | Every installer metafile under `Scripts/dots` parses, declares the keys the installer needs, uses a known action, and declares a usable `source` where it has one. A local entry's `source_root` and its non-glob paths have to exist inside the checkout; an entry backed by a remote `source` is exempt from those checks, since its files come from an archive the installer downloads, but its path types are still checked. It also keeps Grimblast on the fixed official source | | `test_lua_syntax.sh` | Every shipped Lua file parses | | `test_schema.sh` | Generated schema artifacts use the current Lua battery notification daemon rather than the removed shell implementation | | `test_screenshot_wrapper.sh` | Satty receives a compatible default GTK renderer while preserving explicit renderer overrides | diff --git a/tests/python/check_dots.py b/tests/python/check_dots.py index 798f45bfd..e7a44ba91 100644 --- a/tests/python/check_dots.py +++ b/tests/python/check_dots.py @@ -20,9 +20,17 @@ def declared_paths(table: dict) -> list: - """`paths` is a single string or a list of them.""" + """`paths` is a single string or a list of them. + + Anything else is wrapped rather than iterated, so a malformed value is + reported as a wrong type instead of crashing the checker. + """ paths = table.get("paths", []) - return [paths] if isinstance(paths, str) else list(paths) + if isinstance(paths, str): + return [paths] + if isinstance(paths, list): + return list(paths) + return [paths] def has_glob(relative: str) -> bool: @@ -30,22 +38,53 @@ def has_glob(relative: str) -> bool: return any(character in relative for character in "*?[") -def inside_repo(relative: str) -> bool: - """A source path has to stay in the checkout, absolute or `..` included.""" - root = REPO_ROOT.resolve() +def inside(root: pathlib.Path, relative: str) -> bool: + """A declared path has to stay under `root`, absolute or `..` included.""" try: return (root / relative).resolve().is_relative_to(root) - except (OSError, ValueError): + except (OSError, RuntimeError, ValueError): + # Older Python raises RuntimeError rather than OSError on a symlink loop. return False -def entries(document: dict) -> list[tuple[str, dict]]: - """Yields (component, table) for every files table in a metafile.""" +def inside_repo(relative: str) -> bool: + """A source root has to stay in the checkout.""" + return inside(REPO_ROOT.resolve(), relative) + + +def declared_sources(document: dict) -> list[tuple[str, object]]: + """Yields (where, source) for every source declared in a metafile.""" found = [] for component, body in document.items(): if isinstance(body, dict): + if "source" in body: + found.append((component, body["source"])) + for index, table in enumerate(body.get("files", []) or []): + if isinstance(table, dict) and "source" in table: + found.append((f"{component}.files[{index}]", table["source"])) + return found + + +def is_remote_source(source: object) -> bool: + """Only a non-empty string names something the installer can fetch.""" + return isinstance(source, str) and bool(source.strip()) + + +def entries(document: dict) -> list[tuple[str, dict, bool]]: + """Yields (component, table, remote) for every files table in a metafile. + + `remote` is true when the component or the entry declares a usable `source`. + Those files come from an archive the installer downloads, so their paths + resolve against the extracted tree rather than this checkout and cannot be + validated here. A malformed `source` does not earn that exemption. + """ + found = [] + for component, body in document.items(): + if isinstance(body, dict): + component_remote = is_remote_source(body.get("source")) for table in body.get("files", []) or []: - found.append((component, table)) + remote = component_remote or is_remote_source(table.get("source")) + found.append((component, table, remote)) return found @@ -80,7 +119,11 @@ def fail(message: str) -> None: fail(f"{name} is not valid TOML: {error}") continue - for component, table in entries(document): + for where_source, source in declared_sources(document): + if not is_remote_source(source): + fail(f"{name} [{where_source}] declares an unusable source {source!r}") + + for component, table, remote in entries(document): where = f"{name} [{component}.files]" if "paths" not in table: @@ -92,19 +135,26 @@ def fail(message: str) -> None: if action is not None and action not in ACTIONS: fail(f"{where} has an unknown action {action!r}") + for relative in declared_paths(table): + if not isinstance(relative, str): + fail(f"{where} declares a path as {type(relative).__name__}, expected a string") + source_root = table.get("source_root") - if source_root is not None: - if not isinstance(source_root, str): - fail(f"{where} declares source_root as {type(source_root).__name__}, expected a string") - elif not inside_repo(source_root): + if source_root is not None and not isinstance(source_root, str): + fail(f"{where} declares source_root as {type(source_root).__name__}, expected a string") + elif isinstance(source_root, str) and not remote: + if not inside_repo(source_root): fail(f"{where} points outside the repository with source_root {source_root!r}") elif not (REPO_ROOT / source_root).is_dir(): fail(f"{where} points at a missing source_root {source_root!r}") else: + root = (REPO_ROOT / source_root).resolve() for relative in declared_paths(table): if not isinstance(relative, str): - fail(f"{where} declares a path as {type(relative).__name__}, expected a string") - elif not has_glob(relative) and not (REPO_ROOT / source_root / relative).exists(): + continue + if not inside(root, relative): + fail(f"{where} points outside its source_root with path {relative!r}") + elif not has_glob(relative) and not (root / relative).exists(): fail(f"{where} points at a missing path {relative!r}") for component, table in dependencies(document):