Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Operations/SourcePageHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ public function handle(array $input, ?InvocationContext $context = null, ?ToolCo
// A SECRET HAS ONE PLACE TO LIVE (greenhouse evidence/1161).
return ['ok' => false, 'error' => 'that file is where this house keeps a secret: it is not read'];
}
if (SourcePath::isHouseState($file, $root)) {
// SOURCE READS SOURCE, NOT THE RUNNING HOUSE'S STATE (greenhouse decisions/0618): `var/` and
// `storage/` are what the house writes as it runs, some of it a live bearer credential.
return ['ok' => false, 'error' => 'that path is the running house\'s state, not its source: it is not read'];
}
$path = SourcePath::relative($file, $root);
$content = file_get_contents($file);
if ($content === false || !mb_check_encoding($content, 'UTF-8')) {
Expand Down
14 changes: 12 additions & 2 deletions src/Operations/SourceReadHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@
*
* The path discipline is {@see TestHandler}'s: a path that resolves outside the root is refused
* before anything is opened, because once this is an operation the input no longer comes only from
* a person typing on their own machine. Truncation is declared, never silent, and the default slice
* is small on purpose — ask again with `from` rather than paging a whole file into the context.
* a person typing on their own machine. Two things inside the root are refused too: a file the house
* keeps a secret in ({@see SecretFiles}), and the running house's STATE under `var/`/`storage/`
* ({@see SourcePath::isHouseState()}, greenhouse decisions/0618) — this reads SOURCE to edit it, not
* the sessions, tokens and ledgers the house writes as it runs. Truncation is declared, never silent,
* and the default slice is small on purpose — ask again with `from` rather than paging a whole file.
*
* Not found is `ok:false` with a reason, never an exception (same contract as {@see ValidateHandler}).
*/
Expand Down Expand Up @@ -71,6 +74,13 @@ public function handle(array $input): array
// are inside the root and real, but a read does not hand them back. The refusal names the path only.
return ['ok' => false, 'error' => "{$path} is where this house keeps a secret: it is not read"];
}
if (SourcePath::isHouseState($file, $root)) {
// SOURCE:READ READS SOURCE, NOT THE RUNNING HOUSE'S STATE (greenhouse decisions/0618). `var/` and
// `storage/` are what the house writes as it runs — sessions, tokens, enrollments, the agent's
// log, trial copies — and some of it is a live bearer credential (the passkey session store). A
// read of source is not the door to any of it. The refusal names the path only.
return ['ok' => false, 'error' => "{$path} is the running house's state, not its source: it is not read"];
}

$from = \is_int($input['from'] ?? null) ? max(1, $input['from']) : 1;
$budget = \is_int($input['lines'] ?? null) ? $input['lines'] : self::DEFAULT_LINES;
Expand Down
13 changes: 13 additions & 0 deletions src/Operations/TestHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
namespace Milpa\DevTools\Operations;

use Milpa\DevTools\Support\ProcessRunner;
use Milpa\DevTools\Support\SecretFiles;
use Milpa\DevTools\Support\SourcePath;
use Milpa\DevTools\Test\HouseWrites;
use Milpa\DevTools\Test\TemporaryCache;
use Milpa\DevTools\Support\RootResolver;
Expand Down Expand Up @@ -110,6 +112,17 @@ public function handle(array $input): array
if ($absoluta === null) {
return $this->falla("«path» must exist and stay inside {$root} — received: {$ruta}");
}
// `test` RUNS ONLY A TEST (greenhouse decisions/0618, D22): a `.php` file or a directory under the
// house's — or a trial copy's — `tests/`. Any other path is refused, through the same question
// `source:read` asks ({@see SourcePath}): which house is this path from, and is it that house's test?
if (!SourcePath::isTest($absoluta, $root)) {
return $this->falla("«path» must be a test under tests/ — «{$ruta}» is not one");
}
// A path that is, by identity, one of the house's secret files (an operator hardlink under tests/) is
// not run: the same rule the readers ask ({@see SecretFiles}, greenhouse decisions/0618, F3).
if (SecretFiles::holds($absoluta, $root)) {
return $this->falla("«path» must be a test under tests/ — «{$ruta}» is not one");
}
$comando[] = $absoluta;
}

Expand Down
131 changes: 117 additions & 14 deletions src/Support/SecretFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,16 @@
/**
* THE FILES OF A HOUSE THAT HOLD SECRETS — a secret has one place to live.
*
* A reading operation of this package is given to a session, and it returns a slice of any file inside the app
* root. Inside the root sit the files where the house keeps a secret: its environment file and the family beside
* it (`.env`, `.env.local`, `.env.production`…), the envelope a declared key is written to, and Composer's
* credentials. A read does not hand those back — nor a COPY of one a trial or a boot candidate kept.
* A reading operation of this package returns a slice of a file inside the app root; the files where the house
* keeps a secret — its environment file and the family beside it (`.env`, `.env.local`, `.env.production`…), the
* envelope a declared key is written to, and Composer's credentials — are not handed back, nor a copy a trial or
* a boot candidate kept.
*
* `HouseWork` already refuses to write any file whose name begins with `.env`; this says the same of a read, a
* copy and the runner's mask (greenhouse evidence/1161). The named templates are not secrets: they are the
* placeholders a project commits, and they stay readable.
* The rule that NAMES those files is milpa/app-runtime's: a `.env*` basename (bar the committed templates) at any
* depth, and `.milpa/secrets.json` / `auth.json` at any depth, compared case-insensitively — this package keeps
* the same rule so the two never disagree on WHICH files are secret. {@see holds()} asks that rule about the file
* each path resolves to, and ADDS a device+inode check so an operator hardlink to a secret under an innocent name
* is caught too. It never NARROWS the rule: a directory that cannot be listed fails CLOSED (the file is withheld).
*/
final class SecretFiles
{
Expand All @@ -34,12 +36,13 @@ final class SecretFiles
/** `.env*` names that are placeholders, not secrets. */
private const TEMPLATES = ['.env.example', '.env.dist', '.env.template', '.env.sample'];

/** The copy roots a secret file may also sit under — kept in step with {@see SourcePath} (greenhouse decisions/0618). */
private const COPY_GLOBS = ['var/trials/*/copy', 'var/exercises/*/copy', 'var/boot-candidates/*'];

/**
* Whether a path from the root of a house names a file the house keeps a secret in — the file itself, OR a
* copy of it anywhere below (a trial under `var/trials/<id>/copy/`, a boot candidate under
* `var/boot-candidates/<id>/`). Matched case-insensitively: a case-insensitive filesystem (macOS, the
* Desktop's) resolves `.ENV` and `.Milpa/Secrets.json` to the real file, so a match that was byte-exact
* would miss them.
* Whether a path from the root of a house NAMES a file the house keeps a secret in — the `.env` family (bar
* the templates) and the envelope / Composer credentials, at ANY depth, case-insensitively. This is the rule
* milpa/app-runtime keeps; {@see holds()} asks it about the file a path resolves to.
*/
public static function isSecret(string $relative): bool
{
Expand All @@ -58,9 +61,109 @@ public static function isSecret(string $relative): bool
return false;
}

/** Whether a file already admitted by {@see SourcePath::inside()} is one the house keeps a secret in. */
/**
* Whether a file already admitted by {@see SourcePath::inside()} is one the house keeps a secret in. The union
* of three legs, so none NARROWS the published rule (greenhouse decisions/0618):
* - the published name rule over the caller's path exactly — the FLOOR: whatever 0.44.2 refused by name, at
* any depth, is refused;
* - the same name rule over the file each path resolves to (its stored-name path), so two spellings of one
* file get one answer;
* - the file's device+inode against the house's real root/copy-root secret files — adds a hardlink under an
* innocent name, and a root/copy-root secret the name rule does not otherwise recognise.
* A path whose directory cannot be listed is withheld (fail closed).
*/
public static function holds(string $file, string $root): bool
{
return self::isSecret(SourcePath::relative($file, $root));
$canonical = SourcePath::canonical($file, $root);
if ($canonical === null) {
return true;
}

return self::isSecret(SourcePath::relative($file, $root))
|| self::isSecret($canonical)
|| self::isOneOfTheSecretFiles($file, $root);
}

/**
* Whether $file IS one of the house's real secret files, by device + inode. This is what identity ADDS over the
* name rule: an alias (a hardlink under an innocent name) and a root/copy-root secret the name rule does not
* otherwise recognise both resolve to the same inode as the real file. The named secrets are FEW — the
* environment family and the two tails at the house root and each copy root — so the set is small and not gated
* on link count.
*/
private static function isOneOfTheSecretFiles(string $file, string $root): bool
{
$st = @stat($file);
if ($st === false) {
return false;
}

return self::identityInSet(self::secretInodes($root), (int) $st['dev'], (int) $st['ino']);
}

/**
* Whether ($dev, $ino) is in the set — both numbers compared, so a file on another device with the same inode
* NUMBER as a secret is not mistaken for it. The one place that comparison lives, so a test pins it.
*
* @internal
*
* @param list<array{0: int, 1: int}> $inodes
*/
public static function identityInSet(array $inodes, int $dev, int $ino): bool
{
foreach ($inodes as [$edev, $eino]) {
if ($edev === $dev && $eino === $ino) {
return true;
}
}

return false;
}

/**
* The device + inode of every real secret file the house keeps at its root and in each copy. The environment
* family is found by listing each base; the two fixed names are looked up by path, so the entry the filesystem
* resolves is the one compared.
*
* @return list<array{0: int, 1: int}>
*/
private static function secretInodes(string $root): array
{
$rootReal = realpath($root);
if ($rootReal === false) {
return [];
}
$bases = [$rootReal];
foreach (self::COPY_GLOBS as $glob) {
foreach (glob($rootReal . '/' . $glob, \GLOB_ONLYDIR) ?: [] as $copy) {
$bases[] = $copy;
}
}
$inodes = [];
foreach ($bases as $base) {
foreach (scandir($base) ?: [] as $entry) {
if ($entry !== '.' && $entry !== '..' && self::isSecret($entry)) {
self::addInode($inodes, $base . '/' . $entry);
}
}
foreach (self::TAIL as $tail) {
self::addInode($inodes, $base . '/' . $tail);
}
}

return $inodes;
}

/**
* Add the device + inode of a file that exists to the set.
*
* @param list<array{0: int, 1: int}> $inodes
*/
private static function addInode(array &$inodes, string $path): void
{
$st = @stat($path);
if ($st !== false) {
$inodes[] = [$st['dev'], $st['ino']];
}
}
}
Loading
Loading