anastasis

Credential backup and recovery protocol and service
Log | Files | Refs | Submodules | README | LICENSE

check-db-naming.py (6677B)


      1 #!/usr/bin/env python3
      2 """Enforce the database layer naming convention.
      3 
      4 Rules checked:
      5 
      6   1. every public function's name starts with the name of the file it lives in
      7   2. the file's own name uses a prefix from the closed set, or is exempt
      8   3. iterate_ iff the function takes a caller-supplied callback; get_ iff not
      9   4. a .h exists beside every .c, and every .sql that is not schema or
     10      boilerplate is named after the .c that runs it
     11   5. every prepared statement is named after the function preparing it
     12   6. no prepared statement name is used in two files
     13   7. no numeric suffixes and no repeated library prefix
     14   8. no orphaned header: every .h in the API directory has an implementation
     15 
     16 Run from the top of the source tree; exits non-zero on any violation.
     17 """
     18 import re
     19 import sys
     20 import pathlib
     21 
     22 # ---------------------------------------------------------------- CONFIG --
     23 # (impl dir, header dir, symbol prefix, .c basename prefix)
     24 LAYERS = [
     25     ("src/stasis", "src/include/anastasis/anastasis-database",
     26      "ANASTASIS_DB_", ""),
     27 ]
     28 
     29 # Prefixes that describe what the caller gets.
     30 PREFIXES = ("get_", "get_count_", "get_exists_", "iterate_",
     31             "insert_", "update_", "update_to_", "delete_", "do_")
     32 
     33 # Not CRUD: transaction control, DDL, events, memory, session plumbing,
     34 # cancellable handle APIs, and multi-function helper modules.  Keep this list
     35 # short and justified -- every entry is a rule the checker stops enforcing.
     36 EXEMPT = {
     37     # transaction control (declared in transaction.h, implemented in
     38     # anastasis-db_pg.c)
     39     "start", "commit", "rollback", "check_connection", "preflight",
     40     # DDL and maintenance
     41     "create_tables", "drop_tables", "gc", "gc_challenge_codes",
     42     # events (declared in event.h, implemented in anastasis-db_pg.c)
     43     "event_listen", "event_listen_cancel", "event_notify",
     44     # subsystem lifecycle
     45     "init", "init_admin", "fini",
     46 }
     47 
     48 # Headers that hold shared declarations rather than one public function.
     49 HDR_EXEMPT = {"common", "event", "transaction"}
     50 
     51 # Files that are not part of the API surface at all.  Listed by name on
     52 # purpose: a "skip anything called test_*" rule would silently exempt any
     53 # future API function whose name starts with test_, which is exactly how
     54 # ANASTASIS_DB_test_auth_iban_payment() and
     55 # ANASTASIS_DB_test_challenge_code_satisfied() escaped notice.
     56 SKIP = {"anastasis-db_pg", "anastasis-dbinit", "test_anastasis_db"}
     57 
     58 # SQL that belongs to the schema or to the amalgamation boilerplate rather
     59 # than to one API function.
     60 SQL_NOT_A_FUNCTION = {"versioning", "drop", "commit", "procedures-prelude"}
     61 SQL_SCHEMA_RE = re.compile(r"^stasis-\d+$")
     62 # ------------------------------------------------------------ END CONFIG --
     63 
     64 PREP = re.compile(r'PREPARE\s*\(\s*"([A-Za-z0-9_]+)"', re.S)
     65 
     66 errors = []
     67 
     68 
     69 def check_layer(impl_dir, hdr_dir, prefix, fpfx):
     70     impl, hdr = pathlib.Path(impl_dir), pathlib.Path(hdr_dir)
     71     if not impl.is_dir():
     72         return
     73     decl = re.compile(r"^" + re.escape(prefix) + r"(\w+)\s*\((.*?)\)\s*;",
     74                       re.S | re.M)
     75     stmt_owner = {}
     76     stems = set()
     77 
     78     for c in sorted(impl.glob("*.c")):
     79         if c.stem in SKIP:
     80             continue
     81         if fpfx and not c.stem.startswith(fpfx):
     82             continue
     83         stem = c.stem[len(fpfx):] if fpfx else c.stem
     84         stems.add(stem)
     85 
     86         # 2. prefix from the closed set, or exempt
     87         if stem not in EXEMPT and not stem.startswith(PREFIXES):
     88             errors.append(f"{c}: '{stem}' uses no approved prefix")
     89 
     90         # 7. numeric suffix
     91         if re.search(r"\d$", stem):
     92             errors.append(f"{c}: '{stem}' ends in a digit")
     93 
     94         # 4. matching header
     95         h = hdr / (stem + ".h")
     96         if stem not in EXEMPT and not h.exists():
     97             errors.append(f"{c}: no matching header {h}")
     98 
     99         text = c.read_text(errors="replace")
    100 
    101         # 5. prepared statements named after the function
    102         for s in dict.fromkeys(PREP.findall(text)):
    103             if s != stem and not s.startswith(stem + "_"):
    104                 errors.append(
    105                     f"{c}: prepared statement '{s}' is not named after "
    106                     f"'{stem}'")
    107             # 6. cross-file uniqueness
    108             if s in stmt_owner and stmt_owner[s] != str(c):
    109                 errors.append(
    110                     f"{c}: prepared statement '{s}' also prepared in "
    111                     f"{stmt_owner[s]}")
    112             stmt_owner[s] = str(c)
    113 
    114         if not h.exists():
    115             continue
    116         htext = re.sub(r"/\*.*?\*/", "", h.read_text(errors="replace"),
    117                        flags=re.S)
    118         for name, params in decl.findall(htext):
    119             # 1. declared names belong to their file.  Exempt entries are the
    120             # documented multi-function modules, so this does not apply there.
    121             if (stem not in EXEMPT
    122                     and name != stem and not name.startswith(stem + "_")):
    123                 errors.append(
    124                     f"{h}: declares '{name}', which does not belong to "
    125                     f"'{stem}'")
    126             # 3. iterate_ iff callback
    127             has_cb = bool(re.search(r"\w*(Callback|Iterator)\s+\w+", params))
    128             if name.startswith("iterate_") and not has_cb:
    129                 errors.append(
    130                     f"{h}: '{name}' is iterate_ but takes no callback")
    131             if name.startswith("get_") and has_cb:
    132                 errors.append(f"{h}: '{name}' takes a callback; use iterate_")
    133 
    134     # 4. an amalgamated .sql is named after the .c that runs it, so that a
    135     # renamed function cannot leave its stored procedure behind.
    136     for s in sorted(impl.glob("*.sql")):
    137         if s.stem in SQL_NOT_A_FUNCTION or SQL_SCHEMA_RE.match(s.stem):
    138             continue
    139         if s.stem not in stems:
    140             errors.append(f"{s}: no {impl}/{s.stem}.c runs this SQL")
    141 
    142     # 8. every header belongs to an implementation
    143     for h in sorted(hdr.glob("*.h")):
    144         if h.stem in HDR_EXEMPT:
    145             continue
    146         if not (impl / (fpfx + h.stem + ".c")).exists():
    147             errors.append(f"{h}: no implementation {impl / (fpfx + h.stem)}.c")
    148 
    149     # 7. repeated library prefix anywhere in the tree
    150     head = prefix.split("_")[0]
    151     for f in list(impl.glob("*.[ch]")) + list(hdr.glob("*.h")):
    152         if "unc-backup" in f.name:
    153             continue
    154         if re.search(r"\b" + re.escape(head) + r"_" + re.escape(prefix),
    155                      f.read_text(errors="replace")):
    156             errors.append(f"{f}: repeated '{prefix}' prefix")
    157 
    158 
    159 for a in LAYERS:
    160     check_layer(*a)
    161 
    162 for e in errors:
    163     print("ERROR:", e)
    164 print(f"\ndb-naming: {len(errors)} violation(s)")
    165 sys.exit(1 if errors else 0)