mirror of https://github.com/OISF/suricata
parent
b0e63a2c95
commit
adc0f18463
@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def run_suricata_csv(command):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except FileNotFoundError as err:
|
||||
raise RuntimeError(f"Command not found: {command[0]}") from err
|
||||
except subprocess.CalledProcessError as err:
|
||||
stderr = err.stderr.strip() if err.stderr else ""
|
||||
raise RuntimeError(
|
||||
f"Failed to run {' '.join(command)}{': ' + stderr if stderr else ''}"
|
||||
) from err
|
||||
|
||||
output = result.stdout
|
||||
if not output.strip():
|
||||
raise RuntimeError("suricata --list-keywords=csv returned empty output")
|
||||
return output
|
||||
|
||||
|
||||
def find_docs_column(header):
|
||||
lowered = [h.strip().lower() for h in header]
|
||||
for i, name in enumerate(lowered):
|
||||
if name == "documentation":
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def extract_rows(csv_text):
|
||||
reader = csv.reader(csv_text.splitlines(), delimiter=';')
|
||||
|
||||
try:
|
||||
header = next(reader)
|
||||
except StopIteration:
|
||||
return []
|
||||
|
||||
docs_col = find_docs_column(header)
|
||||
rows = []
|
||||
|
||||
for lineno, row in enumerate(reader, start=2):
|
||||
if not row:
|
||||
continue
|
||||
row = [col.strip() for col in row]
|
||||
keyword = row[0] if row else ""
|
||||
|
||||
if docs_col is not None and docs_col < len(row):
|
||||
link = row[docs_col]
|
||||
else:
|
||||
nonempty = [col for col in row if col]
|
||||
link = nonempty[-1] if nonempty else ""
|
||||
|
||||
if not link:
|
||||
continue
|
||||
|
||||
rows.append((lineno, keyword, link.rstrip(';')))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def url_to_local_path(link):
|
||||
parsed = urlparse(link)
|
||||
|
||||
if parsed.scheme not in ("http", "https", ""):
|
||||
return None, None
|
||||
|
||||
path = parsed.path or ""
|
||||
fragment = parsed.fragment or ""
|
||||
|
||||
if not path:
|
||||
return None, fragment
|
||||
|
||||
normalized = path.lstrip('/')
|
||||
if normalized.startswith("en/latest/"):
|
||||
normalized = normalized[len("en/latest/"):]
|
||||
else:
|
||||
return None, fragment
|
||||
|
||||
return normalized, fragment
|
||||
|
||||
|
||||
def read_file(path):
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def anchor_exists(content, fragment):
|
||||
# Sphinx targets are exposed as id=... (and sometimes name=... for legacy anchors).
|
||||
pattern = re.compile(r"(?:id|name)=[\"']%s[\"']" % re.escape(fragment))
|
||||
return pattern.search(content) is not None
|
||||
|
||||
|
||||
def validate_links(rows, html_dir, check_anchors):
|
||||
missing_files = []
|
||||
missing_anchors = []
|
||||
ok = 0
|
||||
|
||||
cache = {}
|
||||
|
||||
for lineno, keyword, link in rows:
|
||||
rel_path, fragment = url_to_local_path(link)
|
||||
if rel_path is None:
|
||||
missing_files.append((lineno, keyword, link, "unsupported or empty path"))
|
||||
continue
|
||||
|
||||
abs_path = os.path.join(html_dir, rel_path)
|
||||
if not os.path.isfile(abs_path):
|
||||
missing_files.append((lineno, keyword, link, rel_path))
|
||||
continue
|
||||
|
||||
if check_anchors and fragment:
|
||||
if abs_path not in cache:
|
||||
cache[abs_path] = read_file(abs_path)
|
||||
if not anchor_exists(cache[abs_path], fragment):
|
||||
missing_anchors.append((lineno, keyword, link, rel_path, fragment))
|
||||
continue
|
||||
|
||||
ok += 1
|
||||
|
||||
return ok, missing_files, missing_anchors
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Run suricata --list-keywords=csv and validate documentation links "
|
||||
"against generated HTML files."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--suricata-bin",
|
||||
default="./src/suricata",
|
||||
help="Path to suricata binary (default: suricata in PATH)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--html-dir",
|
||||
default="doc/userguide/_build/html",
|
||||
help="Path to generated HTML docs directory (default: doc/userguide/_build/html)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-anchor-check",
|
||||
action="store_true",
|
||||
help="Only check that target HTML files exist, do not validate #anchors",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
html_dir = os.path.abspath(args.html_dir)
|
||||
if not os.path.isdir(html_dir):
|
||||
print(f"error: HTML directory not found: {html_dir}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
command = [args.suricata_bin, "--list-keywords=csv"]
|
||||
|
||||
try:
|
||||
csv_output = run_suricata_csv(command)
|
||||
except RuntimeError as err:
|
||||
print(f"error: {err}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
rows = extract_rows(csv_output)
|
||||
if not rows:
|
||||
print("error: no keyword documentation rows found in CSV output", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
ok, missing_files, missing_anchors = validate_links(
|
||||
rows, html_dir, check_anchors=not args.no_anchor_check
|
||||
)
|
||||
|
||||
total = len(rows)
|
||||
|
||||
if missing_files:
|
||||
print("Missing HTML files:")
|
||||
for lineno, keyword, link, detail in missing_files:
|
||||
print(f" keyword '{keyword}': {link} (expected: {detail})")
|
||||
|
||||
if missing_anchors:
|
||||
print("Missing anchors:")
|
||||
for lineno, keyword, link, rel_path, fragment in missing_anchors:
|
||||
print(
|
||||
f" keyword '{keyword}': {link} "
|
||||
f"(file: {rel_path}, anchor: #{fragment})"
|
||||
)
|
||||
|
||||
print(
|
||||
f"Checked {total} documentation links: "
|
||||
f"{ok} OK, {len(missing_files)} missing files, {len(missing_anchors)} missing anchors"
|
||||
)
|
||||
|
||||
return 1 if (missing_files or missing_anchors) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in New Issue