diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9711cdbd..2bd5c176 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -38,20 +38,28 @@ Développeur : Gérald Kerma (Gandalf) — CyberMind, Notre-Dame-du-Cruet, Savoi ### Python ```python -# Entête standard SecuBox-Deb +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + """ SecuBox-Deb :: CyberMind — https://cybermind.fr -Author: Gérald Kerma -License: Proprietary / ANSSI CSPN candidate """ ``` +The SPDX block is added/verified by `scripts/license-headers.py`. + ### Bash ```bash #!/usr/bin/env bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + # SecuBox-Deb :: -# CyberMind — Gérald Kerma set -euo pipefail readonly MODULE="" readonly VERSION="" diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml new file mode 100644 index 00000000..a4ef1c86 --- /dev/null +++ b/.github/workflows/license-check.yml @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +name: License Headers + +on: + pull_request: + push: + branches: [master] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Verify CMSD-1.0 headers + run: python3 scripts/license-headers.py --check diff --git a/scripts/README.md b/scripts/README.md index 4c68bf35..7a045067 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -333,6 +333,42 @@ cp -a /var/lib/secubox/rollback/pre-migration-20260429-143022/* /etc/ --- +## license-headers.py + +CMSD-1.0 SPDX header tool. Adds, verifies, or previews license headers +on all first-party source files. Pure stdlib; no dependencies. + +**Usage:** + +```bash +python3 scripts/license-headers.py --check # CI mode; exit 1 if missing +python3 scripts/license-headers.py --fix # add headers in place +python3 scripts/license-headers.py --list # list files that would be touched +python3 scripts/license-headers.py --diff # unified diff per file (no writes) +python3 scripts/license-headers.py --fix common/ # scope to a path +``` + +**Enrollment allowlist:** `scripts/license-headers-enrolled.txt`. One glob +per line; `#`-prefixed lines are comments. Empty means no enforcement. +Phase A leaves it nearly empty; Phase B adds lines per package; Phase C +deletes it to enforce repo-wide. + +**Optional pre-commit hook** (off by default): + +```yaml +- repo: local + hooks: + - id: license-headers + name: License Headers (CMSD-1.0) + entry: python3 scripts/license-headers.py --fix + language: system + pass_filenames: true +``` + +**Spec:** `docs/superpowers/specs/2026-05-12-license-headers-design.md`. + +--- + ## Environment Variables | Variable | Description | diff --git a/scripts/license-headers-enrolled.txt b/scripts/license-headers-enrolled.txt new file mode 100644 index 00000000..a2acb1a3 --- /dev/null +++ b/scripts/license-headers-enrolled.txt @@ -0,0 +1,8 @@ +# CMSD-1.0 license header enrollment allowlist. +# One glob per line (paths relative to repo root). +# Phase A initial state: empty — CI does not enforce headers yet. +# Phase B adds one line per package as they're enrolled. +# Phase C: this file is deleted; CI then enforces repo-wide. +scripts/license-headers.py +tests/test_license_headers.py +.github/workflows/license-check.yml diff --git a/scripts/license-headers.py b/scripts/license-headers.py new file mode 100644 index 00000000..bbf32fc6 --- /dev/null +++ b/scripts/license-headers.py @@ -0,0 +1,329 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +# scripts/license-headers.py +"""CMSD-1.0 license header tool. + +Adds and verifies the SPDX-License-Identifier: LicenseRef-CMSD-1.0 header +on every first-party source file. See docs/superpowers/specs/2026-05-12-license-headers-design.md. +""" +from __future__ import annotations + +import argparse +import difflib +import fnmatch +import re +import sys +from pathlib import Path + + +HEADER_LINES = ( + "SPDX-License-Identifier: LicenseRef-CMSD-1.0", + "Copyright (c) 2026 CyberMind — Gérald Kerma ", + "Source-Disclosed License — All rights reserved except as expressly granted.", + "See LICENCE-CMSD-1.0.md for terms.", +) + + +_CMSD_ID = "LicenseRef-CMSD-1.0" +# Matches an SPDX line only when preceded by comment markers and/or +# whitespace. Prevents false-matches when a docstring mentions the +# token "SPDX-License-Identifier:" in prose. +_SPDX_LINE_RE = re.compile( + r"^[\s/*#]*\s*SPDX-License-Identifier:\s*(\S+)" +) + +ENROLLMENT_FILE = "scripts/license-headers-enrolled.txt" + + +def detect_existing(text: str) -> str: + """Return 'MATCH', 'FOREIGN', or 'NONE' based on the first 10 lines. + + Only lines whose non-whitespace content begins with comment markers + (#, //, *, ) and then an SPDX identifier count as a license + declaration. Prose mentions inside docstrings are ignored. + """ + for line in text.splitlines()[:10]: + match = _SPDX_LINE_RE.match(line) + if match: + return "MATCH" if match.group(1) == _CMSD_ID else "FOREIGN" + return "NONE" + + +def render_header(style: str) -> str: + if style == "hash": + return "".join(f"# {line}\n" for line in HEADER_LINES) + if style == "slash": + return "".join(f"// {line}\n" for line in HEADER_LINES) + if style == "block": + body = "".join(f" * {line}\n" for line in HEADER_LINES) + return f"/*\n{body} */\n" + if style == "html": + body = "".join(f" {line}\n" for line in HEADER_LINES) + return f"\n" + raise ValueError(f"unknown comment style: {style}") + + +SKIP_DIRS = frozenset({ + "kernel-build", "redroid", "Tow-Boot", + "output", "cache", "backups", "apt", "repo", + "node_modules", ".venv", ".git", "__pycache__", "dist", "build", +}) + +SKIP_GLOBS = ( + "*.min.js", + "*.min.css", + "package-lock.json", + "*.lock", +) + + +LANG_TABLE: dict[str, tuple[str, str]] = { + ".py": ("hash", "python"), + ".sh": ("hash", "shebang_hash"), + ".js": ("slash", "top"), + ".mjs": ("slash", "top"), + ".ts": ("slash", "top"), + ".css": ("block", "top"), + ".c": ("block", "top"), + ".h": ("block", "top"), + ".html": ("html", "html"), + ".htm": ("html", "html"), + ".md": ("html", "markdown"), + ".toml": ("hash", "top"), + ".yaml": ("hash", "top"), + ".yml": ("hash", "top"), + ".conf": ("hash", "top"), +} + + +def _place_python(header: str, text: str) -> str: + lines = text.splitlines(keepends=True) + insert_at = 0 + # Skip shebang + if lines and lines[0].startswith("#!"): + insert_at = 1 + # Skip encoding declaration (PEP 263) + if insert_at < len(lines) and re.match(r"^#.*coding[:=]", lines[insert_at]): + insert_at += 1 + return "".join(lines[:insert_at]) + header + "\n" + "".join(lines[insert_at:]) + + +def _place_shebang_hash(header: str, text: str) -> str: + """Hash-comment language with a shebang line on line 1.""" + lines = text.splitlines(keepends=True) + insert_at = 1 if lines and lines[0].startswith("#!") else 0 + return "".join(lines[:insert_at]) + header + "\n" + "".join(lines[insert_at:]) + + +def _place_top(header: str, text: str) -> str: + """Insert header at line 1 with one blank line separator.""" + return header + "\n" + text + + +def _place_html(header: str, text: str) -> str: + lines = text.splitlines(keepends=True) + insert_at = 0 + if lines and lines[0].lstrip().lower().startswith(" str: + """Place after YAML frontmatter if present, else at top.""" + lines = text.splitlines(keepends=True) + insert_at = 0 + if lines and lines[0].rstrip() == "---": + for i in range(1, len(lines)): + if lines[i].rstrip() == "---": + insert_at = i + 1 + break + return "".join(lines[:insert_at]) + header + "\n" + "".join(lines[insert_at:]) + + +_PLACERS = { + "python": _place_python, + "shebang_hash": _place_shebang_hash, + "top": _place_top, + "html": _place_html, + "markdown": _place_markdown, +} + + +def apply(text: str, ext: str) -> str: + """Insert the CMSD header into `text` for files with extension `ext`. + + Returns `text` unchanged if a CMSD header is already present or if a + foreign SPDX header is detected. + """ + if ext not in LANG_TABLE: + return text + status = detect_existing(text) + if status in ("MATCH", "FOREIGN"): + return text + style, placer_name = LANG_TABLE[ext] + header = render_header(style) + return _PLACERS[placer_name](header, text) + + +def _is_in_scope(path: Path) -> bool: + """True iff `path` has an extension in LANG_TABLE and matches no skip glob.""" + if path.suffix not in LANG_TABLE: + return False + name = path.name + for pat in SKIP_GLOBS: + if fnmatch.fnmatch(name, pat): + return False + return True + + +def _matches_allowlist(rel: Path, enrolled: list[str]) -> bool: + """True iff `rel` matches any glob in `enrolled`.""" + rel_str = rel.as_posix() + for pat in enrolled: + if fnmatch.fnmatch(rel_str, pat): + return True + # Allow "common/**" to match "common/a.py" + if pat.endswith("/**") and ( + rel_str == pat[:-3] or rel_str.startswith(pat[:-2]) + ): + return True + return False + + +def walk(paths: list[Path], enrolled: list[str], repo_root: Path | None = None): + """Yield in-scope files under each path, honoring SKIP_DIRS, SKIP_GLOBS, allowlist. + + `repo_root` (when provided) is the base for allowlist pattern matching. + Allowlist patterns are repo-relative, so a caller walking a subdirectory + must pass `repo_root` separately for the patterns to match. When omitted, + paths are matched relative to their walk root (backwards-compatible). + """ + allowlist_base = repo_root.resolve() if repo_root is not None else None + for root in paths: + root = Path(root) + if root.is_file(): + if not _is_in_scope(root): + continue + rel = ( + root.resolve().relative_to(allowlist_base) + if allowlist_base is not None + else root + ) + if _matches_allowlist(rel, enrolled): + yield root + continue + for p in root.rglob("*"): + # Prune skip-dirs + if any(part in SKIP_DIRS for part in p.relative_to(root).parts): + continue + if not p.is_file(): + continue + if not _is_in_scope(p): + continue + if allowlist_base is not None: + try: + rel = p.resolve().relative_to(allowlist_base) + except ValueError: + continue + else: + rel = p.relative_to(root) + if not _matches_allowlist(rel, enrolled): + continue + yield p + + +def _find_repo_root(start: Path) -> Path: + cur = start.resolve() + while cur != cur.parent: + if (cur / ".git").exists(): + return cur + cur = cur.parent + return start + + +def _read_enrollment(repo_root: Path) -> list[str]: + """Return enrollment patterns from scripts/license-headers-enrolled.txt. + + Phase semantics (per spec §5.2): + * Missing file → ["**"] — repo-wide enforcement (Phase C final state) + * File exists, empty / only comments → [] — nothing enforced (Phase A initial) + * File with patterns → those patterns + """ + f = repo_root / ENROLLMENT_FILE + if not f.exists(): + return ["**"] + patterns: list[str] = [] + for raw in f.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + patterns.append(line) + return patterns + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="CMSD-1.0 license header tool") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--check", action="store_true") + mode.add_argument("--fix", action="store_true") + mode.add_argument("--list", dest="list_", action="store_true") + mode.add_argument("--diff", action="store_true") + parser.add_argument("paths", nargs="*") + + try: + args = parser.parse_args(argv) + except SystemExit as e: + return int(e.code) if isinstance(e.code, int) else 2 + + repo_root = _find_repo_root(Path.cwd()) + enrolled = _read_enrollment(repo_root) + paths = [Path(p) for p in args.paths] if args.paths else [repo_root] + + missing: list[Path] = [] + for p in walk(paths, enrolled, repo_root=repo_root): + text = p.read_text(encoding="utf-8", errors="replace") + status = detect_existing(text) + + if status == "FOREIGN": + print(f"skip (foreign SPDX): {p}", file=sys.stderr) + continue + + if args.list_: + if status == "NONE": + print(p) + continue + + if args.diff: + if status == "NONE": + new = apply(text, p.suffix) + for line in difflib.unified_diff( + text.splitlines(keepends=True), + new.splitlines(keepends=True), + fromfile=str(p), + tofile=str(p), + ): + sys.stdout.write(line) + continue + + if args.fix: + if status == "NONE": + p.write_text(apply(text, p.suffix), encoding="utf-8") + continue + + if args.check: + if status == "NONE": + missing.append(p) + + if args.check and missing: + print("Files missing CMSD-1.0 header:") + for p in missing: + print(f" {p}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_license_headers.py b/tests/test_license_headers.py new file mode 100644 index 00000000..1e99bfad --- /dev/null +++ b/tests/test_license_headers.py @@ -0,0 +1,463 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +# tests/test_license_headers.py +"""Tests for scripts/license-headers.py. + +The tool's filename contains a hyphen so it is loaded via +importlib.util.spec_from_file_location rather than a normal import. +""" +from __future__ import annotations + +import importlib.util +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +_TOOL_PATH = REPO_ROOT / "scripts" / "license-headers.py" + +_spec = importlib.util.spec_from_file_location("license_headers", _TOOL_PATH) +license_headers = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(license_headers) + + +def test_module_imports(): + assert hasattr(license_headers, "main") + + +EXPECTED_HASH_HEADER = ( + "# SPDX-License-Identifier: LicenseRef-CMSD-1.0\n" + "# Copyright (c) 2026 CyberMind — Gérald Kerma \n" + "# Source-Disclosed License — All rights reserved except as expressly granted.\n" + "# See LICENCE-CMSD-1.0.md for terms.\n" +) + + +def test_render_header_hash(): + assert license_headers.render_header("hash") == EXPECTED_HASH_HEADER + + +EXPECTED_SLASH_HEADER = ( + "// SPDX-License-Identifier: LicenseRef-CMSD-1.0\n" + "// Copyright (c) 2026 CyberMind — Gérald Kerma \n" + "// Source-Disclosed License — All rights reserved except as expressly granted.\n" + "// See LICENCE-CMSD-1.0.md for terms.\n" +) + +EXPECTED_BLOCK_HEADER = ( + "/*\n" + " * SPDX-License-Identifier: LicenseRef-CMSD-1.0\n" + " * Copyright (c) 2026 CyberMind — Gérald Kerma \n" + " * Source-Disclosed License — All rights reserved except as expressly granted.\n" + " * See LICENCE-CMSD-1.0.md for terms.\n" + " */\n" +) + +EXPECTED_HTML_HEADER = ( + "\n" +) + + +def test_render_header_slash(): + assert license_headers.render_header("slash") == EXPECTED_SLASH_HEADER + + +def test_render_header_block(): + assert license_headers.render_header("block") == EXPECTED_BLOCK_HEADER + + +def test_render_header_html(): + assert license_headers.render_header("html") == EXPECTED_HTML_HEADER + + +def test_detect_existing_none_on_empty(): + assert license_headers.detect_existing("") == "NONE" + + +def test_detect_existing_none_on_plain_code(): + assert license_headers.detect_existing("print('hello')\n") == "NONE" + + +def test_detect_existing_match_hash(): + text = EXPECTED_HASH_HEADER + "\nprint('hello')\n" + assert license_headers.detect_existing(text) == "MATCH" + + +def test_detect_existing_match_slash(): + text = EXPECTED_SLASH_HEADER + "\nconsole.log('hi');\n" + assert license_headers.detect_existing(text) == "MATCH" + + +def test_detect_existing_match_block(): + text = EXPECTED_BLOCK_HEADER + "\nint main(void) { return 0; }\n" + assert license_headers.detect_existing(text) == "MATCH" + + +def test_detect_existing_match_html(): + text = EXPECTED_HTML_HEADER + "\n\n" + assert license_headers.detect_existing(text) == "MATCH" + + +def test_detect_existing_foreign_mit(): + text = "# SPDX-License-Identifier: MIT\nprint('hello')\n" + assert license_headers.detect_existing(text) == "FOREIGN" + + +def test_detect_existing_foreign_gpl(): + text = "// SPDX-License-Identifier: GPL-2.0-or-later\nint x;\n" + assert license_headers.detect_existing(text) == "FOREIGN" + + +def test_detect_existing_only_checks_first_10_lines(): + """A CMSD line buried deep in the file should NOT be treated as MATCH.""" + text = "\n".join(["# unrelated"] * 20) + "\n# SPDX-License-Identifier: LicenseRef-CMSD-1.0\n" + assert license_headers.detect_existing(text) == "NONE" + + +def test_detect_existing_no_false_match_in_docstring(): + """Prose mentions of SPDX inside docstrings/comments should NOT match. + + Regression: previously the regex matched any 'SPDX-License-Identifier:' + token anywhere in the first 10 lines, including inside Python docstrings + that *describe* what an SPDX header looks like. + """ + text = ( + '"""License header tool.\n' + '\n' + 'Adds the SPDX-License-Identifier: LicenseRef-CMSD-1.0 header.\n' + '"""\n' + 'x = 1\n' + ) + assert license_headers.detect_existing(text) == "NONE" + + +def test_detect_existing_no_false_match_inline_comment_prose(): + """`# Description mentioning SPDX-License-Identifier: ...` is NOT a license line.""" + text = "# This module documents SPDX-License-Identifier: MIT compliance.\nx = 1\n" + assert license_headers.detect_existing(text) == "NONE" + + +def test_apply_python_plain(): + src = '"""Docstring."""\nprint("hi")\n' + out = license_headers.apply(src, ".py") + assert out == EXPECTED_HASH_HEADER + "\n" + src + + +def test_apply_python_idempotent(): + src = '"""Docstring."""\nprint("hi")\n' + once = license_headers.apply(src, ".py") + twice = license_headers.apply(once, ".py") + assert once == twice + + +def test_apply_foreign_python_unchanged(): + src = "# SPDX-License-Identifier: MIT\nprint('x')\n" + out = license_headers.apply(src, ".py") + assert out == src + + +def test_apply_python_with_shebang(): + src = '#!/usr/bin/env python3\nprint("x")\n' + out = license_headers.apply(src, ".py") + assert out.startswith("#!/usr/bin/env python3\n") + assert out.splitlines()[1] == "# SPDX-License-Identifier: LicenseRef-CMSD-1.0" + + +def test_apply_python_with_encoding(): + src = '# -*- coding: utf-8 -*-\nprint("x")\n' + out = license_headers.apply(src, ".py") + assert out.startswith("# -*- coding: utf-8 -*-\n") + assert out.splitlines()[1] == "# SPDX-License-Identifier: LicenseRef-CMSD-1.0" + + +def test_apply_python_with_shebang_and_encoding(): + src = '#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint("x")\n' + out = license_headers.apply(src, ".py") + lines = out.splitlines() + assert lines[0] == "#!/usr/bin/env python3" + assert lines[1] == "# -*- coding: utf-8 -*-" + assert lines[2] == "# SPDX-License-Identifier: LicenseRef-CMSD-1.0" + + +def test_apply_bash_with_shebang(): + src = '#!/usr/bin/env bash\nset -euo pipefail\necho "hi"\n' + out = license_headers.apply(src, ".sh") + assert out.startswith("#!/usr/bin/env bash\n") + assert out.splitlines()[1] == "# SPDX-License-Identifier: LicenseRef-CMSD-1.0" + + +def test_apply_bash_without_shebang(): + src = 'echo "hi"\n' + out = license_headers.apply(src, ".sh") + assert out.startswith("# SPDX-License-Identifier: LicenseRef-CMSD-1.0\n") + + +def test_apply_bash_idempotent(): + src = '#!/usr/bin/env bash\nset -e\n' + once = license_headers.apply(src, ".sh") + twice = license_headers.apply(once, ".sh") + assert once == twice + + +def test_apply_javascript(): + src = 'console.log("hi");\n' + out = license_headers.apply(src, ".js") + assert out == EXPECTED_SLASH_HEADER + "\n" + src + + +def test_apply_typescript(): + src = 'const x: number = 1;\n' + out = license_headers.apply(src, ".ts") + assert out == EXPECTED_SLASH_HEADER + "\n" + src + + +def test_apply_css(): + src = 'body { color: red; }\n' + out = license_headers.apply(src, ".css") + assert out == EXPECTED_BLOCK_HEADER + "\n" + src + + +def test_apply_c(): + src = 'int main(void) { return 0; }\n' + out = license_headers.apply(src, ".c") + assert out == EXPECTED_BLOCK_HEADER + "\n" + src + + +def test_apply_idempotent_all_styles(): + cases = [ + (".js", 'console.log("x");\n'), + (".ts", 'const x = 1;\n'), + (".css", 'a { color: red; }\n'), + (".c", 'int x;\n'), + (".h", '#pragma once\n'), + ] + for ext, src in cases: + once = license_headers.apply(src, ext) + twice = license_headers.apply(once, ext) + assert once == twice, f"non-idempotent for {ext}" + + +def test_apply_html_no_doctype(): + src = 'hi\n' + out = license_headers.apply(src, ".html") + assert out == EXPECTED_HTML_HEADER + "\n" + src + + +def test_apply_html_with_doctype(): + src = '\nhi\n' + out = license_headers.apply(src, ".html") + lines = out.splitlines(keepends=True) + assert lines[0] == "\n" + assert lines[1] == "