ROOTS
ROOTS = ['modules/custom', 'modules', 'themes/custom', 'themes', 'profiles/custom', 'profiles', 'sites/default']
Directories under the project root that hold a site's own code.
Walks the code a site actually owns.
A site's own modules, themes and profiles are what nobody else has a copy of. Measured on a real module, 66 PHP files and 8 metadata files came to 3,010,560 bytes raw and 762,344 compressed, and with delta coding across 150 deploys a year the whole realm costs 8.98 MiB annually. That is cheap enough that not capturing it would be the odd choice.
vendor/ is never walked. It is reproducible from composer.lock, which is 440x smaller than
core's compressed bytes alone, so it is referenced rather than stored - see LockfileReference. The
same goes for node_modules, build output and anything else a tool regenerates.
settings.php is walked but redacted, because it carries both the configuration a restore
needs and the credentials that must not leave the server.
The walk is bounded by extension and by size. A module that ships a 200 MB fixture is not code, and treating it as code would put it in the same realm as the thing whose cost model says 8.98 MiB a year.
EXCLUDED = ['/vendor/', '/node_modules/', '/.git/', '/.svn/', '/dist/', '/build/', '/coverage/', '/.cache/', '/.idea/', '/.vscode/', '/files/', '/php_storage/']
Path fragments never walked, whatever they contain.
Everything a tool regenerates, plus everything a version control system or an editor leaves
behind. vendor and node_modules are the expensive ones and are why this list exists.
EXTENSIONS = ['php', 'module', 'install', 'inc', 'theme', 'profile', 'engine', 'yml', 'yaml', 'twig', 'js', 'css', 'json', 'md', 'txt', 'htaccess', 'sh', 'lock']
Extensions treated as code.
A closed list rather than an exclusion list: a new kind of binary asset appearing in a module should default to being left out of the code realm, not swept into it.
MAX_FILES = 20000
Most files one scan yields.
The walk follows symlinks, because a site's own module is very often a symlink into a separate checkout, and a checkout carries directories this scanner has no business reading. Excluded directories are pruned and symlink loops are refused, so this bound is not what stops a runaway walk - it is what stops one pass appending an operation per file over a tree nobody expected to be there. A scan that reaches it says so rather than returning quietly short.
__construct(string $root, int $maxFiles = \self::MAX_FILES) : mixed
Constructs a scanner.
| string | $root | The project root, which is the directory holding |
| int | $maxFiles | Most files one scan yields. Anything below one is raised to one, since a scanner that yields nothing would report an empty code realm rather than a misconfigured bound. |
scan() : \Generator<string,array{digest: string, bytes: int, redacted: int, contents: string}>
Every code file the site owns, with its content.
A generator, so a scan over a few thousand files does not hold all of them in memory at once.
Path relative to the root, keyed to its digest, size, how many assignments were redacted, and the content as it will be stored.
digest() : string
A digest over everything the scan would store.
One number that changes when any of the site's own code changes, which is what a deploy detector compares. Computed from the per-file digests in a sorted order so it does not depend on the order the filesystem hands files back.
The digest, or the digest of an empty string when the site owns no code.
measure() : array{files: int, bytes: int, redacted: int, truncated: bool}
What the scan would cost, without storing anything.
How many files, how many bytes, how many secret assignments were replaced, and whether the walk stopped at the file bound.
identity(string $directory) : string
What identifies a directory for the loop guard.
The real path, which is what makes two routes to the same directory one entry and is therefore
what catches a symlink pointing at an ancestor. realpath() returns FALSE for a path on a
stream wrapper, and a test root under vfs:// is exactly that, so the path itself stands in.
Nothing is lost: a stream wrapper has no symlinks to loop through.
| string | $directory | The path as it was reached. |
The identity.
read(string $path, \SplFileInfo $file) : array{digest: string, bytes: int, redacted: int, contents: string}|null
Reads one file, redacting it when it holds secrets.
| string | $path | Path relative to the root. |
| \SplFileInfo | $file | The file. |
What to store, or NULL when the file is out of scope, too large or unreadable.
walk(string $directory) : \Generator<int,\SplFileInfo>
Walks a directory, pruning what is excluded before descending into it.
Pruning at the directory rather than filtering at the file is the whole point. Deciding per
file means vendor/ is still walked - 241 MB and tens of thousands of entries - to discard
every one of them, and the docblock claim that it is never walked would be false.
Symlinks are followed, because a site's own module is very often a symlink into a separate checkout and refusing to follow one would silently leave that module out of the backup. A followed symlink can point at an ancestor, so a directory whose real path has already been entered is refused; without that the walk does not terminate.
| string | $directory | Absolute path. |
The files found.
descend(string $directory, array$entered) : \Generator<int,\SplFileInfo>
Yields the files under one directory, recursing into the ones in scope.
| string | $directory | Absolute path, keeping whatever symlink it was reached through so the path a file is stored under stays the path the site sees. |
| array |
$entered | Real paths already descended into, by reference so one loop guard covers the whole walk. |
The files found.