#!/usr/bin/env python3 """Compare the public API of Spring Security 6.5.11 against 7.1.2 straight from the jars. Writes output/01-versions-and-baseline.txt and output/02-removed-api.txt. Needs a JDK (javap) on PATH or via JAVA_HOME, and access to Maven Central. """ import collections import os import pathlib import re import subprocess import time import urllib.error import urllib.request import zipfile ROOT = pathlib.Path(__file__).resolve().parent.parent JARS = ROOT / "target" / "jars" OUT = ROOT / "output" JAVAP = os.path.join(os.environ["JAVA_HOME"], "bin", "javap") if os.environ.get("JAVA_HOME") else "javap" OLD, NEW = "6.5.11", "7.1.2" ARTIFACTS = [("security", a) for a in ["spring-security-core","spring-security-config","spring-security-web","spring-security-crypto","spring-security-oauth2-core","spring-security-oauth2-client","spring-security-oauth2-resource-server","spring-security-oauth2-jose","spring-security-test"]] # New 7.x artifacts that took over classes: a class missing from its old jar may simply have moved here. RELOCATION_TARGETS = ["spring-security-access", "spring-security-webauthn"] def version_for(group, which): return NEW if which == "new" else OLD def fetch(url, dest): if dest.exists(): return for _ in range(8): try: with urllib.request.urlopen(url) as r: dest.write_bytes(r.read()) return except urllib.error.HTTPError as e: if e.code != 429: raise time.sleep(4) raise RuntimeError("rate limited: " + url) def jar(group, artifact, which): v = version_for(group, which) dest = JARS / f"{artifact}-{v}.jar" fetch(f"https://repo1.maven.org/maven2/org/springframework/security/{artifact}/{v}/{artifact}-{v}.jar", dest) return dest def classes(path): with zipfile.ZipFile(path) as z: return sorted(n[:-6].replace("/", ".") for n in z.namelist() if n.endswith(".class") and not n.endswith("module-info.class") and "META-INF" not in n) def major(path): with zipfile.ZipFile(path) as z: name = next(n for n in z.namelist() if n.endswith(".class") and not n.endswith("module-info.class") and "META-INF" not in n) b = z.read(name) return int.from_bytes(b[6:8], "big") def norm(sig): """Erase generics and the throws clause, so only a real change of name, parameters or return type counts.""" sig = re.sub(r"\s+throws\s+.*$", "", sig) prev = None while prev != sig: prev = sig sig = re.sub(r"<[^<>]*>", "", sig) return " ".join(sig.split()) MEMBER = re.compile(r"^ ((?:public|protected)[^;]*);$") def dump(path): """One javap call per jar. Returns {class: (is_public, class_deprecated, {member signature: deprecated?})}.""" names = [c for c in classes(path) if ".internal." not in c] result = {} # javap takes many class names at once; split the list to keep the command line short for i in range(0, len(names), 150): chunk = names[i:i + 150] p = subprocess.run([JAVAP, "-v", "-protected", "-cp", str(path)] + chunk, capture_output=True, text=True) cur_cls = None for block in re.split(r"(?m)^Classfile ", p.stdout)[1:]: lines = block.splitlines() header = lines[0] cls = re.search(r"!/(.*)\.class", header).group(1).replace("/", ".") public = False cls_dep = False members, cur, in_pool, seen_flags = {}, None, False, False for line in lines[1:]: if not seen_flags and line.startswith(" flags:"): public = "ACC_PUBLIC" in line seen_flags = True if line.startswith("Constant pool:"): in_pool = True continue if in_pool: if line.startswith("{"): in_pool = False continue m = MEMBER.match(line) if m: cur = " ".join(m.group(1).split()) members[cur] = False continue if line.strip() == "Deprecated: true": if cur is None: cls_dep = True else: members[cur] = True if line.startswith("}"): cur = None if re.search(r"^Deprecated: true", block, re.M): cls_dep = True result[cls] = (public, cls_dep, members) return result def main(): JARS.mkdir(parents=True, exist_ok=True) OUT.mkdir(parents=True, exist_ok=True) versions = [f"{'artifact':<34} {'6.x jar':<28} {'7.x jar':<28}"] moved_to = {} for art in RELOCATION_TARGETS: j = jar("security", art, "new") for c in classes(j): moved_to[c] = art removed_classes = collections.defaultdict(list) removed_members = collections.defaultdict(list) added = collections.Counter() for group, art in ARTIFACTS: old, new = jar(group, art, "old"), jar(group, art, "new") mo, mn = major(old), major(new) versions.append(f"{art:<34} {version_for(group, 'old')} (Java {mo - 44}){'':<9} {NEW} (Java {mn - 44})") do, dn = dump(old), dump(new) co, cn = set(classes(old)), set(classes(new)) for c in sorted(co - cn): if c in do and do[c][0]: removed_classes[art].append((c, do[c][1])) added[art] = len(cn - co) for c in sorted(do): if c in dn and do[c][0]: new_norm = {norm(x) for x in dn[c][2]} for sig, dep in do[c][2].items(): if norm(sig) not in new_norm: removed_members[art].append((c, sig, dep)) (OUT / "01-versions-and-baseline.txt").write_text("\n".join(versions) + "\n") lines = [] for _, art in ARTIFACTS: lines.append(f"== {art}: public classes removed: {len(removed_classes[art])}, " f"members removed from classes that remain: {len(removed_members[art])}, " f"new classes: {added[art]}") for c, dep in removed_classes[art]: note = f"; moved to {moved_to[c]}" if c in moved_to else "; gone" lines.append(f" class {c} [{'deprecated in 6.5' if dep else 'NOT deprecated in 6.5'}{note}]") for c, sig, dep in removed_members[art]: lines.append(f" member {c}: {sig} [{'deprecated in 6.5' if dep else 'NOT deprecated in 6.5'}]") moved = sum(1 for a in removed_classes for c, _ in removed_classes[a] if c in moved_to) total = sum(len(v) for v in removed_classes.values()) lines.append(f"TOTAL public classes missing from the old jars: {total}, of which found again in {', '.join(RELOCATION_TARGETS)}: {moved}") (OUT / "02-removed-api.txt").write_text("\n".join(lines) + "\n") print("\n".join(versions)) print("\n".join(l for l in lines if l.startswith("=="))) if __name__ == "__main__": main()