"""Read AUR package metadata, statically. .SRCINFO is a stable, machine-generated format and is the right interface for reading AUR metadata. A PKGBUILD is not: it is executable shell owned by whoever maintains the package. `source`-ing one to inspect it runs their code at *review* time, before any decision to trust it has been made. Everything here is static parsing, and the tool must never grow a "just source it to be sure" shortcut. """ from __future__ import annotations import platform import re from dataclasses import dataclass, field from pathlib import Path # fields that are lists and may legitimately repeat _LIST_FIELDS = { "arch", "license", "makedepends", "checkdepends", "depends", "optdepends", "provides", "conflicts", "replaces", "source", "sha256sums", "sha512sums", "b2sums", "md5sums", "validpgpkeys", "options", "backup", "groups", "noextract", } #: architectures Arch recognises in `.SRCINFO` suffixed keys _ARCHES = ("x86_64", "aarch64", "armv7h", "i686", "riscv64", "pentium4", "any") #: the one this machine builds for, so another architecture's deps and sources #: do not become edges here _HOST_ARCH = platform.machine() _VER_CONSTRAINT = re.compile(r"(>=|<=|=|>|<)") def dep_name(spec: str) -> str: """`python>=3.14` -> `python`; `foo: for bar` -> `foo`.""" spec = spec.split(":", 1)[0].strip() return _VER_CONSTRAINT.split(spec, 1)[0].strip() @dataclass class SrcInfo: pkgbase: str = "" pkgname: list[str] = field(default_factory=list) pkgver: str = "" pkgrel: str = "" epoch: str = "" url: str = "" install: str = "" makedepends: list[str] = field(default_factory=list) checkdepends: list[str] = field(default_factory=list) depends: list[str] = field(default_factory=list) optdepends: list[str] = field(default_factory=list) provides: list[str] = field(default_factory=list) conflicts: list[str] = field(default_factory=list) replaces: list[str] = field(default_factory=list) source: list[str] = field(default_factory=list) sha256sums: list[str] = field(default_factory=list) sha512sums: list[str] = field(default_factory=list) b2sums: list[str] = field(default_factory=list) md5sums: list[str] = field(default_factory=list) validpgpkeys: list[str] = field(default_factory=list) arch: list[str] = field(default_factory=list) license: list[str] = field(default_factory=list) options: list[str] = field(default_factory=list) backup: list[str] = field(default_factory=list) groups: list[str] = field(default_factory=list) noextract: list[str] = field(default_factory=list) # per-subpackage depends, kept separate so a subpackage's deps are visible per_package_depends: dict[str, list[str]] = field(default_factory=dict) @property def sums(self) -> list[str]: """Every checksum list this package declares, in preference order. A package may use only b2sums or only sha512sums. Reading just sha256sums made those packages look as though they declared no checksums at all, and the audit treats "no checksums on a network source" as nothing to check -- so a SKIP introduced on a non-VCS source went unreported. """ return [*self.sha256sums, *self.sha512sums, *self.b2sums, *self.md5sums] @property def has_sums(self) -> bool: return bool(self.sums) @property def primary_sums(self) -> list[str]: """The checksum list that parallels `source`. A PKGBUILD uses one checksum array for its source list, so the first non-empty one is the list to pair against `source` by index. Concatenating them (`sums`) would misalign the indices for any package that declares more than one kind. """ for candidate in (self.sha256sums, self.sha512sums, self.b2sums, self.md5sums): if candidate: return candidate return [] @property def version(self) -> str: core = f"{self.pkgver}-{self.pkgrel}" if self.pkgrel else self.pkgver return f"{self.epoch}:{core}" if self.epoch else core @property def build_depends(self) -> list[str]: """Everything needed before `package()` runs, deduped, order preserved.""" seen: dict[str, None] = {} for spec in (*self.makedepends, *self.checkdepends, *self.depends): seen.setdefault(dep_name(spec), None) return list(seen) def declared_names(self) -> list[str]: return self.pkgname or ([self.pkgbase] if self.pkgbase else []) def parse_srcinfo(text: str) -> SrcInfo: info = SrcInfo() for raw in text.splitlines(): line = raw.strip() if not line or "=" not in line: continue key, _, value = line.partition("=") key, value = key.strip(), value.strip() if not key or not value: continue # `.SRCINFO` carries `source_x86_64`, `depends_aarch64` and the like. # Without this the arch-specific entries match no field at all and are # dropped, so a package whose sources are arch-suffixed looks like it # has no sources and no checksums -- a silent false negative on the # architecture actually being built for. # Matched against the known names rather than split on the last # underscore: the architecture names contain one, so `x86_64` would # otherwise split into `source_x86` and `64`. Only this machine's # architecture is merged -- another one's dependencies and sources do # not apply here and would become false edges in the build graph. for arch in (_HOST_ARCH, "any"): if key.endswith(f"_{arch}") and key != f"_{arch}": base = key[: -len(arch) - 1] if base in _LIST_FIELDS or hasattr(info, base): key = base break if key == "pkgbase": info.pkgbase = value current_pkg = None continue if key == "pkgname": info.pkgname.append(value) current_pkg = value info.per_package_depends.setdefault(value, []) continue if key == "depends" and current_pkg is not None: info.per_package_depends[current_pkg].append(value) info.depends.append(value) continue if key in _LIST_FIELDS: getattr(info, key, None) if hasattr(info, key) and isinstance(getattr(info, key), list): getattr(info, key).append(value) continue if hasattr(info, key): setattr(info, key, value) return info # -------------------------------------------------------------------------- # static PKGBUILD parsing # -------------------------------------------------------------------------- @dataclass class Pkgbuild: """A statically parsed PKGBUILD. `raw` is kept for pattern scanning.""" raw: str vars: dict[str, list[str]] = field(default_factory=dict) funcs: dict[str, str] = field(default_factory=dict) #: `raw` with comments removed. Whole-file rules scan this instead, because a #: comment saying "never do `curl ... | sh` here" is not a network call. #: Suppression also looks for the pattern anywhere in the previous revision, #: so the same comment there suppresses a genuine new finding. code: str = "" def get(self, name: str) -> list[str]: return self.vars.get(name, []) def one(self, name: str) -> str: v = self.vars.get(name) return v[0] if v else "" def _strip_comment(line: str) -> str: """Remove a trailing comment, the way the shell decides what one is. A `#` only begins a comment when it starts a word: at the beginning of the line, or just after whitespace, and never inside quotes. Shell does not treat every hash as a comment and neither may we, because PKGBUILDs are full of literal ones -- `git+https://host/r.git#tag=v1.0`, `${#arr[@]}`, `$#`. Treating them as comments was expensive. It truncated array elements mid-value, which removed a `}` or `{` and skewed brace depth so one function swallowed the next; a swallowed function is a function the audit never scans. It also silently dropped checksums, leaving the audit with nothing to compare. """ quote = "" i = 0 while i < len(line): char = line[i] if quote: if char == "\\" and quote == '"': i += 2 continue if char == quote: quote = "" elif char in "'\"": quote = char elif char == "#" and (i == 0 or line[i - 1] in " \t"): return line[:i] i += 1 return line def _skip_substitution(text: str, i: int) -> int: """Index just past a `$(...)` or `${...}` starting at `i`, or -1 if unclosed. Substitutions are their own quoting context. `"$(sed -n "s/x/y/p" f)"` is valid shell: the inner quotes belong to the substitution and do not close the outer one. Reading them as ordinary quotes left the scanner convinced a quote was still open, so a function body never appeared to end and it swallowed every function after it -- which meant the audit never saw them. """ if i + 1 >= len(text) or text[i] != "$" or text[i + 1] not in "({": return -1 opener = text[i + 1] closer = ")" if opener == "(" else "}" depth = 1 quote = "" j = i + 2 while j < len(text): char = text[j] if quote: if char == "\\" and quote == '"': j += 2 continue if char == quote: quote = "" elif char in "'\"": quote = char elif char == opener: depth += 1 elif char == closer: depth -= 1 if depth == 0: return j + 1 j += 1 return -1 def _scan_array_end(body: str) -> int: """Index of the `)` that closes an array whose `(` was just consumed. Quote-aware, because a quoted element may contain a `)`: `'foo: does things (fast)'`. Stopping at the first one truncated the element, left the quotes unbalanced, and made the fallback split the rest of the array on whitespace -- losing entries outright. Command substitutions are skipped over, so `source=($(printf '%s' a))` still finds the array's own closing paren. """ quote = "" i = 0 while i < len(body): char = body[i] if char == "\\" and quote != "'": i += 2 continue if quote == "'": if char == "'": quote = "" i += 1 continue if char == '"': quote = "" if quote == '"' else '"' i += 1 continue if char == "'" and quote == "": # only opens a quote outside one; a `'` inside "..." is literal quote = "'" i += 1 continue # a substitution is its own quoting context even inside double quotes, # which is where they usually are: `"$(sed -n "s/x/y/p" f)"` if char == "$" and (body.startswith("$(", i) or body.startswith("${", i)): # `$(( 1 + 2 ))` nests, so a plain depth counter gets this wrong and # mistakes the arithmetic's own closing paren for the array's end = _skip_substitution(body, i) if end < 0: return -1 i = end continue if quote == "" and char == ")": return i i += 1 return -1 #: `foo{,.sig}` and friends. Non-nested only, and never a `${substitution}` -- #: `https://x/f-${pkgver}.tar.gz{,.sig}` has two brace groups and only the second #: one is a list. _BRACE = re.compile(r"(? list[str]: """Expand a bash brace list, e.g. `sdl2-1.0.tar.gz{,.sig}` -> two entries. Bash performs this at parse time, and .SRCINFO records the expanded result, so a source entry with a brace list is really several sources. Leaving it unexpanded made `source` shorter than `sha256sums`, which shifted every checksum onto the wrong source -- and the audit pairs them by position. """ match = _BRACE.search(word) if not match: return [word] body = match.group(1) if "," not in body: return [word] # `{a}` is literal in bash, not a list out: list[str] = [] for option in body.split(","): out.extend(_expand_braces(word[: match.start()] + option + word[match.end() :], limit)) if len(out) > limit: return [word] # pathological; better to under-expand than explode return out def _split_words(body: str) -> list[str]: """Split an array body into elements. No variable expansion. Hand-written rather than shlex, because shlex splits on the whitespace *inside* a command substitution: `$(_checksum ${_tag})` came back as `['$(_checksum', '${_tag})']`, which detaches a source or a checksum from the thing that identifies it. Substitutions are kept whole, and quoted text keeps its spaces. """ words: list[str] = [] current: list[str] = [] quote = "" i = 0 while i < len(body): char = body[i] if quote: if char == "\\" and quote == '"' and i + 1 < len(body): current.append(body[i + 1]) i += 2 continue if char == quote: quote = "" else: current.append(char) elif char in "'\"": quote = char elif char == "\\": # a backslash-newline continues the word; otherwise it escapes if i + 1 < len(body) and body[i + 1] == "\n": i += 2 continue if i + 1 < len(body): current.append(body[i + 1]) i += 2 continue elif body.startswith("$(", i) or body.startswith("${", i): opener = body[i + 1] closer = ")" if opener == "(" else "}" depth = 1 # consume both `$` and its opener before scanning, or the loop counts # the opener a second time and swallows the rest of the array current.append(body[i]) current.append(opener) i += 2 while i < len(body) and depth > 0: inner = body[i] if inner == opener: depth += 1 elif inner == closer: depth -= 1 current.append(inner) i += 1 continue elif char.isspace(): if current: words.append("".join(current)) current = [] else: current.append(char) i += 1 if current: words.append("".join(current)) expanded: list[str] = [] for word in words: expanded.extend(_expand_braces(word)) return expanded #: a function body is delimited by one of these pairs _OPENERS = {"{": "}", "(": ")"} def _func_head(stripped: str) -> tuple[str, str, str] | None: """`(name, opener, remainder)` for a function definition line, else None. Function names may contain more than word characters: split packages use `package_()`, and real PKGBUILDs have hyphenated names, so the character class has to allow them or those bodies are never parsed and never scanned. """ match = re.match(r"^([A-Za-z_][A-Za-z0-9_.+-]*)\s*\(\s*\)\s*(.*)$", stripped) if not match: return None name, rest = match.group(1), match.group(2).strip() opener = "" if rest[:1] in _OPENERS: opener, rest = rest[0], rest[1:] elif rest: return None # something else on the line: not a definition after all return name, opener, rest def _body_closed(text: str, opener: str, closer: str) -> bool: """Whether every unquoted opener in `text` has been closed. Quote-aware on purpose. A `}` inside a quoted string used to be counted, so one stray brace made a function swallow the next one -- and a swallowed function is a function the audit never looks at. """ quote = "" depth = 0 i = 0 while i < len(text): char = text[i] if char == "\\" and quote != "'": i += 2 continue if quote == "'": if char == "'": quote = "" i += 1 continue if char == '"': quote = "" if quote == '"' else '"' i += 1 continue if char == "'" and quote == "": # only opens a quote outside one; a `'` inside "..." is literal quote = "'" i += 1 continue # inside double quotes a substitution is still its own context, and the # braces of `${...}` must not be counted towards the body's depth if char == "$" and (text.startswith("$(", i) or text.startswith("${", i)): end = _skip_substitution(text, i) if end < 0: return False # unclosed substitution: keep reading lines i = end continue if quote == "": if char == opener: depth += 1 elif char == closer: depth -= 1 i += 1 return depth <= 0 and not quote def _strip_body(text: str, opener: str, closer: str) -> str: """Drop the single enclosing pair of delimiters.""" text = text.strip() if opener and text.startswith(opener): text = text[1:] text = text.rstrip() if closer and text.endswith(closer): text = text[:-1] return text.strip() def parse_pkgbuild(text: str) -> Pkgbuild: """Parse assignments and function bodies without executing anything. Deliberately not a shell. It handles the shapes PKGBUILDs actually take (scalar assignments, quoted arrays spanning lines, `name() { ... }`, `name() ( ... )`) and records anything it does not understand verbatim in `raw`, which is what the audit scans. It never expands variables. """ pb = Pkgbuild(raw=text) pb.code = "\n".join(_strip_comment(line) for line in text.splitlines()) lines = text.splitlines() i = 0 while i < len(lines): stripped = _strip_comment(lines[i]).strip() head = _func_head(stripped) if head is not None: name, opener, rest = head if not opener: # `prepare()` with the brace on the following line nxt = _strip_comment(lines[i + 1]).strip() if i + 1 < len(lines) else "" if nxt[:1] in _OPENERS: opener, rest = nxt[0], nxt[1:] i += 1 if opener: closer = _OPENERS[opener] # keep the opener in the text being balanced: `check() (` has an # empty remainder, which would otherwise already look closed and # the whole body would be skipped body = opener + rest while not _body_closed(body, opener, closer) and i + 1 < len(lines): i += 1 body += "\n" + _strip_comment(lines[i]) body = _strip_body(body, opener, closer) else: body = rest pb.funcs[name] = body i += 1 continue # `name[3]=value` assigns one element, which is how a package sets a # single checksum to SKIP without rewriting the whole array indexed = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\[(\d+)\]\s*=\s*(.+)$", stripped) if indexed: name, slot, value = (indexed.group(1), int(indexed.group(2)), indexed.group(3).strip()) if len(value) >= 2 and value[0] in "'\"" and value.endswith(value[0]): value = value[1:-1] current = pb.vars.setdefault(name, []) while len(current) <= slot: current.append("") current[slot] = value i += 1 continue arr = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*(\+?=)\s*\((.*)$", stripped) if arr: name, operator, rest = arr.group(1), arr.group(2), arr.group(3) while _scan_array_end(rest) < 0 and i + 1 < len(lines): i += 1 rest += "\n" + _strip_comment(lines[i]) end = _scan_array_end(rest) if end >= 0: rest = rest[:end] words = _split_words(rest) if operator == "=": pb.vars[name] = words else: pb.vars.setdefault(name, []).extend(words) i += 1 continue scalar = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$", stripped) if scalar: value = scalar.group(2).strip() # keep the whole value: `pkgdesc=hello world` is one string, and # taking the first word silently truncated every unquoted one if len(value) >= 2 and value[0] in "'\"" and value.endswith(value[0]): value = value[1:-1] pb.vars.setdefault(scalar.group(1), []).append(value) i += 1 continue i += 1 return pb def load(clone_dir: Path, package: str) -> tuple[SrcInfo | None, Pkgbuild | None]: """Read both metadata files for a cloned AUR package. Never executes them.""" base = clone_dir / package srcinfo = pkgbuild = None si = base / ".SRCINFO" if si.is_file(): srcinfo = parse_srcinfo(si.read_text(errors="replace")) pk = base / "PKGBUILD" if pk.is_file(): pkgbuild = parse_pkgbuild(pk.read_text(errors="replace")) return srcinfo, pkgbuild