--- title: "pkgaudit Internals" output: rmarkdown::html_vignette: # Rule messages quote shell flags and make constructs verbatim, so the # "smart" typography that would turn --clean into an en dash, or # $(shell ...) into an ellipsis, is disabled. md_extensions: "-smart" vignette: > %\VignetteIndexEntry{pkgaudit Internals} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", echo = FALSE ) # Render a markdown table, one row per line, escaping any cell content that # would otherwise be read as a column separator. emit_table <- function(headers, ...) { cells <- lapply(list(...), function(x) gsub("|", "\\|", x, fixed = TRUE)) cat( c( paste0("| ", paste(headers, collapse = " | "), " |"), paste0("|", strrep("---|", length(headers))), paste0("| ", do.call(paste, c(cells, sep = " | ")), " |") ), sep = "\n" ) cat("\n\n") } library(pkgaudit) ``` This vignette is for someone reading pkgaudit's source code. For how to use pkgaudit and what it reports, see [Getting Started](pkgaudit.html); for the rule set, see [Rule Coverage](rules.html). ## pkgaudit steps `audit_package()` orchestrates a series of function calls, which may be loosely grouped as four steps: 1. Discover 2. Extract 3. Analyze 4. Resolve ### Discover `find_file_contexts()` runs one `list.files()` per file-context rule, using a file context rule's `path`, `filename` and `recursive` fields. If `report = TRUE` in a rule, the file context is reported in the pkgaudit object's `$file_contexts` data frame. ### Extract For each file context, whether reported or not, `extract_segments()` dispatches to S3 methods that read each file into *segments*: contiguous runs of code in one language, blank-padded so that line *n* of a segment is line *n* of the source file. Dispatch is based on the file-context rule's `type`. The methods live in `R/extract_*.R`. ```{r results = "asis"} emit_table( "Extractors", list.files( path = "../R", pattern = "^extract_[a-zA-Z]+\\.R$" ) ) ``` A type read like another inherits from it rather than repeating a method. For example, `make` inherits `shell` and `qmd` inherits `Rmd`. `extract_segments()` has a default method that fails closed: a type with no extractor yields no segments. ### Analyze For each segment extracted, `analyze_segment()` dispatches to S3 methods that call functions to find code contexts, patterns, or matches in each segment. Dispatch is based on the segment's `language`, such as R or shell. The methods live in `R/analyze_*.R`. ```{r results = "asis"} emit_table( "Analyzers", list.files( path = "../R", pattern = "^analyze_[a-zA-Z]+\\.R$" ) ) ``` `analyze_segment.R` (in `R/analyze_R.R`) calls functions including `parse_code()`, which uses `parse()` and `xmlparsedata::xml_parse_data()` to derive an XML parse tree for R code; and `find_patterns()` and `find_indirect()`, which use the parse tree and `xml2::xml_find_all()` to find patterns and indirect calls using function names (e.g., `do.call("system", ...)`). `determine_code_contexts()` determines the code context of patterns and indirect calls. `analyze_segment.shell` (in `R/analyze_shell.R`) calls functions including `find_matches()`, which uses `gregexpr(perl = TRUE)` to find text matching regular expressions in shell and Make-like files, and `bash`, `sh`, and `zsh` vignette chunks. `analyze_segment()` has a default method that fails closed: a language with no analyzer yields no findings and no error, recording a coverage row instead. ### Resolve A set of internal functions uses file or code contexts to resolve the phases in which a pattern or match may run. The supported phases are: ```{r phases-legend, results = "asis"} rules <- load_rules() phase_names <- setdiff(names(rules$phases), c("context", "version")) phase_descriptions <- c( at_autoconf = "Autoconf is run to generate `configure`", at_build = "`R CMD build`", at_check = "`R CMD check`", at_install_src = "`R CMD INSTALL` from source", at_install_bin = "a prebuilt binary package is installed", at_load = "the namespace is loaded", at_attach = "the package is attached to the search path", at_unload = "the namespace is unloaded", at_detach = "the package is detached from the search path" ) stopifnot(setequal(names(phase_descriptions), phase_names)) emit_table( c("Phase", "Code executes when"), paste0("`", phase_names, "`"), phase_descriptions[phase_names] ) ``` Pattern phases are resolved from code and file contexts, in that order. A pattern in a code context defined by a rule, such as `.onLoad`, takes that context's phases. A pattern in top-level code takes its file context's phases. A pattern in a regular function definition depends on the `assume_called` field of the file-context rule: if `TRUE`, it takes the phases of whatever encloses it; if `FALSE`, as under `R/`, it receives no phase. (A helper defined under `R/` may still be called from a lifecycle hook, and human reviewers should verify whether that occurs.) Match phases are resolved from file contexts. A rule can belong to several phases. Phases were established by running instrumented packages and recording which sites fired, rather than read from documentation. ## Call list The `audit_package()` call list below is derived from pkgaudit's source code. A name appears once, at the first place it is reached; a function called from several places is not repeated. The tree is cut at four levels deep. ```{r call-graph, comment = ""} # Read the package's own sources. r_dir <- if (dir.exists("../R")) "../R" else "R" stopifnot(dir.exists(r_dir)) # Every function the package defines, and which of them each one calls. The # names in function position are all.names() less all.vars(). defined <- list() for (f in list.files(r_dir, pattern = "[.]R$", full.names = TRUE)) { for (e in parse(f, keep.source = FALSE)) { if (!is.call(e) || length(e) < 3L) next if (!as.character(e[[1L]]) %in% c("<-", "=")) next if (!is.call(e[[3L]]) || !identical(as.character(e[[3L]][[1L]]), "function")) next nm <- as.character(e[[2L]]) defined[[nm]] <- setdiff(all.names(e[[3L]]), all.vars(e[[3L]])) } } own <- names(defined) # S3 dispatch is invisible to a static reader: a generic calls UseMethod(), not # its methods. The methods are attached to their generic so the tree shows what # a scan actually does. for (g in c("extract_segments", "analyze_segment")) { methods <- grep(paste0("^", g, "[.]"), own, value = TRUE) defined[[g]] <- unique(c(setdiff(defined[[g]], "UseMethod"), methods)) } render <- function(fn, prefix = "", seen = character(0), depth = 0L) { callees <- intersect(defined[[fn]], own) callees <- setdiff(callees, seen) if (depth >= 4L) callees <- character(0) out <- character(0) for (i in seq_along(callees)) { last <- i == length(callees) out <- c(out, paste0(prefix, if (last) "└─ " else "├─ ", callees[[i]], "()"), render(callees[[i]], paste0(prefix, if (last) " " else "│ "), c(seen, callees), depth + 1L)) } out } cat("audit_package()", render("audit_package", seen = "audit_package"), sep = "\n") ``` ## Invariants ### Scanned files are never executed Since pkgaudit is intended for reviewing untrusted packages, it must never execute code contained in those packages. `tests/testthat/test-no_execution.R` builds a package where every execution site writes a marker file, scans it, exports it, and requires that none of the markers exist afterwards. Three calls in the read path look like execution and are not. `tools::parse_Rd()` parses without evaluating; `tools::loadPkgRdMacros()` reads macro definitions as text; and `parse(text = )` produces an unevaluated expression. Macros come from the package's own `man/macros/` and from any provider named in its `RdMacros` field; both are read as text, not evaluated. ### File size limits are applied before extraction A file over 10 MB is not read. `extract_segments()` applies this restriction before `UseMethod()` so that no method can skip it. `tests/testthat/test-segments.R` verifies that an oversized file is not scanned and that the refusal is recorded in `errors`. ### Failures are contained and counted Listing files, reading a file, parsing code, and evaluating an XPath or a regular expression are each wrapped in `tryCatch()`: a failure becomes a row in `errors` rather than aborting the scan. ### Rules are integrity-checked Rules live in a SQLite database built from YAML by `inst/scripts/build_db.R`. `load_rules()` verifies the database against its SHA-256 sidecar on every call. The check is time-of-check to time-of-use; see "Security considerations" in `?load_rules`. `load_rules()` also refuses a database with a gap in its phase model, so a rule whose findings would carry no phases can never ship. ### Every rule is verified against its own examples Each rule carries positive and negative examples in its YAML. `build_db.R` writes them out as fixtures, and `tests/testthat/test-fixtures.R` requires every positive to match its rule and every negative to not match.