\Drupal\strata\Code CodeScanner

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.

Summary

Methods
Properties
Constants
__construct
scan
wasTruncated
digest
measure
lockfiles
root
maxFiles
isRedacted
isCode
isExcludedDirectory
No public properties found
ROOTS
EXCLUDED
EXTENSIONS
MAX_BYTES
MAX_FILES
REDACTED
No protected methods found
No protected properties found
No protected constants found
identity
read
walk
descend
path
relative
truncated
maxFiles
root
No private constants found

Constant

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.

EXCLUDED

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

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_BYTES

MAX_BYTES = 2097152

Largest file walked as code.

A code file above this is a fixture, a minified bundle or a mistake, and the file realm is where large content belongs.

MAX_FILES

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.

REDACTED

REDACTED = ['sites/default/settings.php', 'sites/default/settings.local.php']

Files that are code but carry secrets, relative to the project root.

Properties

$truncated

$truncated : bool

Whether the last scan stopped at the file bound.

Type

bool

$maxFiles

$maxFiles : int

Most files this scanner yields.

Type

int

$root

$root : string

Type

string

Methods

__construct()

__construct(string  $root, int  $maxFiles = \self::MAX_FILES) : mixed

Constructs a scanner.

Parameters

string $root

The project root, which is the directory holding composer.json and the docroot.

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.

Returns

mixed —

scan()

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.

Returns

\Generator

Path relative to the root, keyed to its digest, size, how many assignments were redacted, and the content as it will be stored.

wasTruncated()

wasTruncated() : bool

Whether the last scan stopped at the file bound rather than running out of files.

Meaningful only after a scan has been consumed, since a generator does nothing until it is iterated.

Returns

bool —

TRUE when files were left unread.

digest()

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.

Returns

string —

The digest, or the digest of an empty string when the site owns no code.

measure()

measure() : array{files: int, bytes: int, redacted: int, truncated: bool}

What the scan would cost, without storing anything.

Returns

array{files: int, bytes: int, redacted: int, truncated: bool} —

How many files, how many bytes, how many secret assignments were replaced, and whether the walk stopped at the file bound.

lockfiles()

lockfiles() : array<string,\Drupal\strata\Code\LockfileReference>

The lockfiles that stand in for `vendor/`.

Returns

array

Path keyed to its reference.

root()

root() : string

The project root this scanner walks.

Returns

string —

The root.

maxFiles()

maxFiles() : int

The file bound this scanner walks under.

Returns

int —

Most files one scan yields.

isRedacted()

isRedacted(string  $path) : bool

Whether a path is one of the files stored with its secrets replaced.

Parameters

string $path

Path relative to the root.

Returns

bool —

TRUE when the file is redacted before storage.

isCode()

isCode(string  $path) : bool

Whether a path is walked at all.

Parameters

string $path

Path relative to the root.

Returns

bool —

TRUE when the path is in scope and has a code extension.

isExcludedDirectory()

isExcludedDirectory(string  $name) : bool

Whether a directory name is one the walk never descends into.

Parameters

string $name

The directory's own name, not its path.

Returns

bool —

TRUE when the directory is pruned.

identity()

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.

Parameters

string $directory

The path as it was reached.

Returns

string —

The identity.

read()

read(string  $path, \SplFileInfo  $file) : array{digest: string, bytes: int, redacted: int, contents: string}|null

Reads one file, redacting it when it holds secrets.

Parameters

string $path

Path relative to the root.

\SplFileInfo $file

The file.

Returns

array{digest: string, bytes: int, redacted: int, contents: string}|null —

What to store, or NULL when the file is out of scope, too large or unreadable.

walk()

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.

Parameters

string $directory

Absolute path.

Returns

\Generator

The files found.

descend()

descend(string  $directory, array  $entered) : \Generator<int,\SplFileInfo>

Yields the files under one directory, recursing into the ones in scope.

Parameters

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.

Returns

\Generator

The files found.

path()

path(string  $relative) : string

An absolute path under the root.

Parameters

string $relative

Path relative to the root.

Returns

string —

The absolute path.

relative()

relative(string  $absolute) : string

A path relative to the root.

Parameters

string $absolute

The absolute path.

Returns

string —

The relative path, or an empty string when it is not under the root.