#!/usr/bin/env python3 """Inventory mars_nwe AFP endpoint coverage before the final WebSDK/header audit. The helper is intentionally read-only. It scans src/nwconn.c and prints a compact table of AFP subfunction names, likely handler functions, implementation status, and the mars_nwe backend families touched by each handler. It does not replace the final manual WebSDK / Novell header comparison. Its job is to make that review reproducible and to flag drift-prone markers such as old transition labels, Netatalk/libatalk references, or AFP-local shortcuts. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Dict, Iterable, List, Tuple CALL_NAME_RE = re.compile( r"case\s+0x([0-9a-fA-F]+)\s*:\s*return\s*\(\s*\"([^\"]+)\"\s*\)\s*;" ) STATIC_AFP_FUNC_RE = re.compile( r"static\s+int\s+(afp_[A-Za-z0-9_]+)\s*\([^)]*\)\s*\{", re.MULTILINE ) # These aliases deliberately mirror the documented NetWare AFP subfunction # table. They make the report stable for endpoints that use shared helper # functions or generic dispatch wrappers where the source function name is not a # direct mechanical spelling of afp_call_name(). Each candidate is used only if # the function exists in the scanned source. HANDLER_CANDIDATES_BY_CALL: Dict[int, Tuple[str, ...]] = { 0x01: ("afp_create_directory", "afp_create_directory_or_file", "afp_create_file_or_directory", "afp_create_file_dir"), 0x02: ("afp_create_file", "afp_create_directory_or_file", "afp_create_file_or_directory", "afp_create_file_dir"), 0x03: ("afp_delete", "afp_delete_file_or_directory", "afp_delete_file_dir"), 0x04: ("afp_get_entry_id_from_name",), 0x05: ("afp_get_file_information",), 0x06: ("afp_get_entry_id_from_netware_handle",), 0x07: ("afp_rename", "afp_rename_file_or_directory", "afp_rename_file_dir"), 0x08: ("afp_open_file_fork",), 0x09: ("afp_set_file_information",), 0x0A: ("afp_scan_file_information",), 0x0B: ("afp_alloc_temporary_dir_handle",), 0x0C: ("afp_get_entry_id_from_path_name",), 0x0D: ("afp_create_directory", "afp_create_directory_or_file", "afp_create_file_or_directory", "afp_create_file_dir"), 0x0E: ("afp_create_file", "afp_create_directory_or_file", "afp_create_file_or_directory", "afp_create_file_dir"), 0x0F: ("afp_get_file_information",), 0x10: ("afp_set_file_information",), 0x11: ("afp_scan_file_information",), 0x12: ("afp_get_dos_name_from_entry_id",), 0x13: (), } # Some AFP endpoints are implemented directly in the AFP dispatch switch rather # than as standalone static afp_* functions. Keep those visible in the report # so the final audit can focus on WebSDK/header semantics instead of parser # limitations. These labels are intentionally conservative: they describe the # implementation shape and backend families, not final compatibility status. INLINE_ENDPOINTS: Dict[int, Tuple[str, str, Tuple[str, ...]]] = { 0x01: ("inline AFP create directory case", "implemented", ("mars_nwe path/namespace", "mars_nwe object lifecycle")), 0x02: ("inline AFP create file case", "implemented", ("mars_nwe path/namespace", "mars_nwe object lifecycle", "AFP-only xattrs via nwatalk")), 0x03: ("inline AFP delete case", "implemented", ("mars_nwe path/namespace", "mars_nwe object lifecycle")), 0x07: ("inline AFP rename case", "implemented", ("mars_nwe path/namespace", "mars_nwe object lifecycle", "AFP-only xattrs via nwatalk")), 0x08: ("inline AFP open file fork case", "implemented", ("mars_nwe path/namespace", "mars_nwe file handles/I/O")), 0x09: ("inline AFP set file information case", "implemented", ("mars_nwe path/namespace", "mars_nwe attributes/archive/fileinfo", "mars_nwe trustee/rights", "AFP-only xattrs via nwatalk")), 0x0B: ("inline AFP alloc temporary dir handle case", "implemented", ("mars_nwe path/namespace", "mars_nwe directory handles")), 0x0D: ("inline AFP 2.0 create directory case", "implemented", ("mars_nwe path/namespace", "mars_nwe object lifecycle")), 0x0E: ("inline AFP 2.0 create file case", "implemented", ("mars_nwe path/namespace", "mars_nwe object lifecycle", "AFP-only xattrs via nwatalk")), 0x10: ("inline AFP 2.0 set file information case", "implemented", ("mars_nwe path/namespace", "mars_nwe attributes/archive/fileinfo", "mars_nwe trustee/rights", "AFP-only xattrs via nwatalk")), 0x13: ("inline AFP deleted-info case", "implemented", ("mars_nwe salvage/deleted-entry backend", "AFP FinderInfo snapshot")), } BACKEND_RULES: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( ("mars_nwe path/namespace", ( "conn_get_kpl_path", "conn_get_kpl_unxname", "afp_build_base_relative_path", "afp_resolve_path_volume", "afp_namespace_path_from_entry_id", "afp_resolve_entry_id_path", "afp_find_dos_name_from_entry_id", "afp_fill_file_info_response", "map_directory_number_to_path", "name_2_base", "build_dos_name", "nw_get_directory_path", )), ("mars_nwe directory handles", ( "nw_alloc_dir_handle", "nw_open_dir_handle", "nw_dealloc_dir_handle", )), ("mars_nwe file handles/I/O", ( "nw_open_creat", "nw_creat_open_file", "nw_close_file", "nw_read_file", "nw_write_file", "get_unix_name_from_fhandle", "file_handle", )), ("mars_nwe attributes/archive/fileinfo", ( "nw_get_fattrib", "nw_set_fattrib", "get_nw_attrib_dword", "set_nw_attrib_dword", "mars_nwe_get_archive_info", "mars_nwe_set_archive_info", "nwarchive_", "nw_get_file_info", "nw_set_file_info", "nw_sattr", )), ("mars_nwe trustee/rights", ( "tru_get_eff_rights", "get_eff_rights", "TRUSTEE_", "no Modify rights", )), ("mars_nwe object lifecycle", ( "nw_creat", "nw_mk_rd_dir", "nw_delete_files", "mv_dir", "nw_mv_dir_between_handles", "unlink", "rmdir", )), ("AFP-only xattrs via nwatalk", ( "nwatalk_get_entry_id", "nwatalk_set_entry_id", "nwatalk_get_finder_info", "nwatalk_set_finder_info", "nwatalk_get_afp_attributes", "nwatalk_set_afp_attributes", )), ) GLOBAL_MARKERS: Tuple[Tuple[str, str], ...] = ( ("layout=websdk", "transient SetInfo layout marker should be gone"), ("libatalk", "Netatalk/libatalk backend reference should be gone"), ("Netatalk", "Netatalk/libatalk backend reference should be gone"), ("HAVE_NETATALK", "Netatalk build gate should be gone"), ("ENABLE_NETATALK", "Netatalk build gate should be gone"), ("entry-id-only lookup unsupported", "entry-id-only resolver gap may remain"), ("entry-id-only scan unsupported", "entry-id-only scan gap may remain"), ) AFP_LOCAL_SHORTCUT_RE = re.compile( r"\b(open|creat|read|write|close|rename|unlink|remove|rmdir|mkdir|" r"getxattr|setxattr|removexattr)\s*\(" ) def repo_root_from(start: Path) -> Path: cur = start.resolve() while True: if (cur / "src" / "nwconn.c").exists(): return cur if cur.parent == cur: raise SystemExit("cannot find repository root containing src/nwconn.c") cur = cur.parent def line_for_offset(text: str, offset: int) -> int: return text.count("\n", 0, offset) + 1 def extract_function_bodies(text: str) -> Dict[str, Tuple[int, str]]: bodies: Dict[str, Tuple[int, str]] = {} for match in STATIC_AFP_FUNC_RE.finditer(text): name = match.group(1) start = match.end() depth = 1 pos = start while pos < len(text) and depth: ch = text[pos] if ch == "{": depth += 1 elif ch == "}": depth -= 1 pos += 1 bodies[name] = (line_for_offset(text, match.start()), text[start:pos - 1]) return bodies def parse_dispatch(text: str, function_names: Iterable[str]) -> Dict[int, str]: names = sorted(function_names, key=len, reverse=True) dispatch: Dict[int, str] = {} # The AFP dispatcher contains nested switch/if blocks, so a simple # ``case ... break;`` regex often stops at an inner break and misses the # real handler call. Walk each AFP case from its label to the next top-level # case/default label and search that slice instead. labels = list(re.finditer(r"^\s*case\s+0x([0-9a-fA-F]+)\s*:", text, re.MULTILINE)) for index, label in enumerate(labels): start = label.end() end = labels[index + 1].start() if index + 1 < len(labels) else len(text) body = text[start:end] for name in names: if name in body: dispatch[int(label.group(1), 16)] = name break return dispatch def classify_status(body: str) -> str: lowered = body.lower() if "unsupported" in lowered and "return(-0x9c" in lowered: return "unsupported subset" if "rejected" in lowered and "return(-0x9c" in lowered: return "partial / validates rejects" if "not implemented" in lowered: return "not implemented marker" if body.strip(): return "implemented" return "unknown" def classify_backends(body: str) -> List[str]: found: List[str] = [] for label, needles in BACKEND_RULES: if any(needle in body for needle in needles): found.append(label) return found or ["needs manual review"] def function_for_call(call_name: str, call_no: int, dispatch: Dict[int, str], bodies: Dict[str, Tuple[int, str]]) -> str: if call_no == 0x13: return "" for candidate in HANDLER_CANDIDATES_BY_CALL.get(call_no, ()): if candidate in bodies: return candidate if call_no in dispatch: return dispatch[call_no] # afp_call_name() returns human labels such as # ``AFP 2.0 Get File Information``. Handler functions are named without # the cosmetic prefix/version marker, e.g. ``afp_get_file_information``. # Keep several conservative variants so the helper remains useful if a # handler keeps an older shorter name. normalized = re.sub(r"[^a-z0-9]+", "_", call_name.lower()).strip("_") if normalized.startswith("afp_"): normalized = normalized[4:] without_version = normalized.replace("2_0_", "") candidates = [ "afp_" + normalized, "afp_" + without_version, "afp_" + without_version.replace("file_information", "file_info"), "afp_" + without_version.replace("directory", "dir"), "afp_" + without_version.replace("netware", "netware"), ] for candidate in candidates: if candidate in bodies: return candidate return "" def find_shortcuts(func_name: str, body: str) -> List[str]: allowed_prefixes = ( "nw_", "nwatalk_", "nwxattr_", "conn_", "afp_", "GET_", "U32_", "mem", "str", "XDPRINTF", ) hits: List[str] = [] for match in AFP_LOCAL_SHORTCUT_RE.finditer(body): name = match.group(1) start = max(0, match.start() - 12) prefix = body[start:match.start()] token = prefix.split()[-1] if prefix.split() else "" if token.endswith(allowed_prefixes): continue # stat/lstat may still be legitimate verification around mars_nwe # path resolution, so report it as review material rather than failing. hits.append(f"{func_name}: {name}()") return sorted(set(hits)) def render_markdown(rows: List[dict], warnings: List[str]) -> str: out: List[str] = [] out.append("# AFP endpoint inventory") out.append("") out.append("| Subfn | Name | Handler | Status | Backend notes |") out.append("| --- | --- | --- | --- | --- |") for row in rows: out.append( "| 0x{call_no:02x} | {name} | {handler} | {status} | {backends} |".format( call_no=row["call_no"], name=row["name"].replace("|", "\\|"), handler=row["handler"] or "manual review", status=row["status"], backends="
".join(row["backends"]), ) ) out.append("") out.append("## Review warnings") out.append("") if warnings: for warning in warnings: out.append(f"- {warning}") else: out.append("- none") out.append("") return "\n".join(out) def main(argv: List[str]) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo", type=Path, default=Path.cwd(), help="mars_nwe repository root") parser.add_argument("--json", action="store_true", help="print JSON instead of markdown") parser.add_argument("--strict", action="store_true", help="exit non-zero when warnings are found") args = parser.parse_args(argv) root = repo_root_from(args.repo) nwconn = root / "src" / "nwconn.c" text = nwconn.read_text(errors="replace") call_names = { int(call_no, 16): name for call_no, name in CALL_NAME_RE.findall(text) if name.startswith("AFP ") } bodies = extract_function_bodies(text) dispatch = parse_dispatch(text, bodies) rows: List[dict] = [] warnings: List[str] = [] for call_no in sorted(call_names): name = call_names[call_no] handler = function_for_call(name, call_no, dispatch, bodies) line = None body = "" if handler and handler in bodies: line, body = bodies[handler] if body: status = classify_status(body) backends = classify_backends(body) elif call_no in INLINE_ENDPOINTS: inline_handler, status, inline_backends = INLINE_ENDPOINTS[call_no] handler = inline_handler backends = list(inline_backends) else: status = "unknown" backends = ["needs manual review"] rows.append({ "call_no": call_no, "name": name, "handler": handler, "line": line, "status": status, "backends": backends, }) if body: for hit in find_shortcuts(handler, body): warnings.append(f"AFP-local shortcut candidate: {hit}") for marker, reason in GLOBAL_MARKERS: if marker in text: for line_no, line in enumerate(text.splitlines(), 1): if marker in line: warnings.append(f"{nwconn.relative_to(root)}:{line_no}: {reason}: {marker}") payload = {"endpoints": rows, "warnings": sorted(set(warnings))} if args.json: print(json.dumps(payload, indent=2, sort_keys=True)) else: print(render_markdown(rows, payload["warnings"])) return 1 if args.strict and payload["warnings"] else 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))