Skip to content

Commit 977dbff

Browse files
committed
sdk%feat(lint): avoid footguns when working with symlinks
1 parent 6d5239c commit 977dbff

2 files changed

Lines changed: 121 additions & 0 deletions

File tree

‎maint/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use [`lint_all.py`](./lint_all.py).
1717
| [`lint_python.py`](./lint/lint_python.py) | Lint Python sources against `[tool.ruff]` options in [`pyproject.toml`](../pyproject.toml) | *None* | `ruff` |
1818
| [`lint_rust.py`](./lint/lint_rust.py) | Lint Rust sources against [`rustfmt.toml`](../rustfmt.toml) | *None* | `cargo`, `rustfmt` |
1919
| [`lint_semgrep.py`](./lint/lint_semgrep.py) | Lint source code against [`maint/semgrep`](./semgrep/rust) definitions | *None* | `semgrep` |
20+
| [`lint_symlinks.py`](./lint/lint_symlinks.py) | Lint symbolic links | *None* | `git` |
2021
| [`lint_unconv.py`](./lint/lint_unconv.py) | Lint commit names in ranges specified against [`unconv.toml`](./unconv.toml) | `run` | `git` |
2122

2223
## Generating lockfiles

‎maint/lint/lint_symlinks.py‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env python3
2+
# coding: latin-1
3+
4+
#
5+
# Copyright (c) 2026-present, The Dash Core developers
6+
# SPDX-License-Identifier: MIT
7+
# See the accompanying file LICENSE or https://opensource.org/license/MIT
8+
#
9+
10+
"""Validate symbolic link integrity.
11+
12+
All symbolic links must meet these criteria:
13+
- They must be a soft link (hard-links are filesystem-level, not file-level)
14+
- They must use relative paths (to prevent them from breaking in containers)
15+
- They must not dangle (i.e. point to non-existent resources)
16+
- They must not have a depth >1 (i.e. cannot point to another symlink)
17+
- They must not point to resources outside the repository (to prevent escapes)
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import posixpath
23+
import shutil
24+
import subprocess
25+
import sys
26+
from pathlib import Path
27+
28+
_GIT = shutil.which("git") or "git"
29+
30+
# Mode the index records for a symlink, and the two it records for a file.
31+
_MODE_LINK = "120000"
32+
_MODE_FILE = ("100644", "100755")
33+
34+
35+
def _git_out(cwd: Path, *args: str) -> str:
36+
"""Run a git command in *cwd*, raise on failure, return its output."""
37+
result = subprocess.run( # noqa: S603
38+
[_GIT, *args],
39+
capture_output=True,
40+
check=False,
41+
cwd=str(cwd),
42+
text=True,
43+
)
44+
if result.returncode != 0:
45+
fault = result.stderr.strip() or result.stdout.strip()
46+
raise RuntimeError(f"git {args[0]}: {fault or result.returncode}")
47+
return result.stdout.strip()
48+
49+
50+
def _tracked(repo_root: Path) -> list[tuple[str, str, str]]:
51+
"""Return `(mode, blob, path)` for every entry the index holds."""
52+
entries: list[tuple[str, str, str]] = []
53+
for record in _git_out(repo_root, "ls-files", "-s", "-z").split("\0"):
54+
if not record:
55+
continue
56+
meta, _, path = record.partition("\t")
57+
mode, blob = meta.split()[:2]
58+
entries.append((mode, blob, path))
59+
return entries
60+
61+
62+
def _link_fault(
63+
repo_root: Path,
64+
modes: dict[str, str],
65+
path: str,
66+
target: str,
67+
) -> str | None:
68+
"""Return why *path* is an unacceptable link, or None when it is fine."""
69+
if posixpath.isabs(target):
70+
return "absolute target"
71+
dest = posixpath.normpath(posixpath.join(posixpath.dirname(path), target))
72+
if dest.startswith(".."):
73+
return "target outside the repository"
74+
if dest not in modes:
75+
return "target is not tracked"
76+
if modes[dest] == _MODE_LINK:
77+
return "target is itself a link"
78+
if not (repo_root / dest).exists():
79+
return "target is missing on disk"
80+
return None
81+
82+
83+
def _hard_link_fault(repo_root: Path, path: str) -> str | None:
84+
"""Return if *path* is hard linked, or None when it holds one name."""
85+
try:
86+
names = (repo_root / path).lstat().st_nlink
87+
except OSError:
88+
return None
89+
return f"hard linked, {names} names" if names > 1 else None
90+
91+
92+
def main() -> int:
93+
here = Path(__file__).resolve().parent
94+
repo_root = Path(_git_out(here, "rev-parse", "--show-toplevel"))
95+
entries = _tracked(repo_root)
96+
modes = {path: mode for mode, _, path in entries}
97+
98+
faults: list[str] = []
99+
for mode, blob, path in sorted(entries, key=lambda entry: entry[2]):
100+
if mode == _MODE_LINK:
101+
target = _git_out(repo_root, "cat-file", "blob", blob)
102+
fault = _link_fault(repo_root, modes, path, target)
103+
if fault is not None:
104+
faults.append(f"{path} -> {target}: {fault}")
105+
elif mode in _MODE_FILE:
106+
fault = _hard_link_fault(repo_root, path)
107+
if fault is not None:
108+
faults.append(f"{path}: {fault}")
109+
110+
for fault in faults:
111+
print(fault, file=sys.stderr)
112+
return 1 if faults else 0
113+
114+
115+
if __name__ == "__main__":
116+
try:
117+
sys.exit(main())
118+
except Exception as exc: # noqa: BLE001
119+
print(exc, file=sys.stderr)
120+
sys.exit(1)

0 commit comments

Comments
 (0)