diff --git a/src/Operations/SourcePageHandler.php b/src/Operations/SourcePageHandler.php index 512ff24..3342514 100644 --- a/src/Operations/SourcePageHandler.php +++ b/src/Operations/SourcePageHandler.php @@ -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')) { diff --git a/src/Operations/SourceReadHandler.php b/src/Operations/SourceReadHandler.php index e0e459c..da490c2 100644 --- a/src/Operations/SourceReadHandler.php +++ b/src/Operations/SourceReadHandler.php @@ -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}). */ @@ -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; diff --git a/src/Operations/TestHandler.php b/src/Operations/TestHandler.php index d18f222..340eca2 100644 --- a/src/Operations/TestHandler.php +++ b/src/Operations/TestHandler.php @@ -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; @@ -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; } diff --git a/src/Support/SecretFiles.php b/src/Support/SecretFiles.php index ec81377..8b69f55 100644 --- a/src/Support/SecretFiles.php +++ b/src/Support/SecretFiles.php @@ -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 { @@ -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//copy/`, a boot candidate under - * `var/boot-candidates//`). 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 { @@ -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 $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 + */ + 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 $inodes + */ + private static function addInode(array &$inodes, string $path): void + { + $st = @stat($path); + if ($st !== false) { + $inodes[] = [$st['dev'], $st['ino']]; + } } } diff --git a/src/Support/SourcePath.php b/src/Support/SourcePath.php index cbc581d..730e4ea 100644 --- a/src/Support/SourcePath.php +++ b/src/Support/SourcePath.php @@ -10,9 +10,41 @@ namespace Milpa\DevTools\Support; -/** The shared realpath boundary for line reads and source pages. */ +/** + * The shared realpath boundary for the operations that return a file's content by path: line reads, source + * pages, and `test`. One question answers for all of them (greenhouse decisions/0618): **which house is this path + * from, and is it that house's source or its state?** + * + * The rules are asked about the file each path resolves to, not about how the path was spelled, so two spellings of + * one file get one answer (greenhouse decisions/0618). {@see canonical()} walks from the root down and, at each + * level, asks the directory which entry the component actually IS — by device + inode — and takes that entry's + * STORED name; the state and test rules read that canonical path, matching the segment case-insensitively, the same + * way the secret rule does. When a directory on the way cannot be listed the path cannot be canonicalised: the + * answer then fails CLOSED (state, not source; not a test), because a file whose place in the house cannot be + * verified is not handed out as source. + */ final class SourcePath { + /** Top-level directories that are a house's STATE, not its source. */ + private const STATE_DIRS = ['var', 'storage']; + + /** + * The roots, under a house's `var/`, of a COPY of that house (greenhouse decisions/0618, D20). A trial, an + * exercise and a boot candidate are each a copy of the house's tree: their SOURCE is source, their own + * `var/`/`storage/` is state. These patterns mirror where milpa/app-runtime makes the copies — + * `TrialWorkspace` (`var/trials//copy`, `var/exercises//copy`) and `BootCandidate` + * (`var/boot-candidates/`). devtools does not depend on app-runtime, so the list lives here; if + * app-runtime adds a copy kind this must gain it (a stated coupling, greenhouse evidence/1199). Matched over + * the canonical path and case-insensitively, so a folded or recased spelling of a copy root still strips. + * + * @var list + */ + private const COPY_ROOTS = [ + '#^(var/trials/[^/]+/copy)/#i', + '#^(var/exercises/[^/]+/copy)/#i', + '#^(var/boot-candidates/[^/]+)/#i', + ]; + /** Resolve a regular file strictly inside the host, after resolving links and traversal. */ public static function inside(string $root, string $path): ?string { @@ -26,7 +58,7 @@ public static function inside(string $root, string $path): ?string return str_starts_with($real, $rootReal . '/') ? $real : null; } - /** A stable relative name for a file already admitted by inside(). */ + /** A stable relative name for a file already admitted by inside() — the caller's spelling, unfolded. */ public static function relative(string $file, string $root): string { $file = str_replace('\\', '/', $file); @@ -34,4 +66,137 @@ public static function relative(string $file, string $root): string return str_starts_with($file, $prefix) ? substr($file, \strlen($prefix)) : $file; } + + /** + * The path of $file relative to $root with the STORED name of every segment — the name the directory actually + * holds, found by identity (device + inode), not by how the caller spelled it. Returns null when a directory on + * the way cannot be listed (so the path's identity cannot be established) or the file is outside the root. + */ + public static function canonical(string $file, string $root): ?string + { + $rootReal = realpath($root); + $fileReal = realpath($file); + if ($rootReal === false || $fileReal === false || !str_starts_with($fileReal, $rootReal . '/')) { + return null; + } + $current = $rootReal; + $names = []; + foreach (explode('/', substr($fileReal, \strlen($rootReal) + 1)) as $segment) { + $stored = self::storedName($current, $current . '/' . $segment); + if ($stored === null) { + return null; + } + $names[] = $stored; + $current .= '/' . $stored; + } + + return implode('/', $names); + } + + /** + * Whether a file already admitted by {@see inside()} is the STATE of the house it belongs to (greenhouse + * decisions/0618, D20), judged over its canonical path and matched case-insensitively — the same comparison the + * secret rule uses. A file inside a COPY of the house is judged against the copy's root. A path that cannot be + * canonicalised is treated as state (the safe direction). + */ + public static function isHouseState(string $file, string $root): bool + { + $canonical = self::canonical($file, $root); + if ($canonical === null) { + return true; + } + + return \in_array(strtolower(self::firstSegment($canonical)), self::STATE_DIRS, true); + } + + /** + * Whether a file already admitted by {@see inside()} is a TEST of the house it belongs to — a `.php` file or a + * directory under that house's (or a copy's) `tests/` (greenhouse decisions/0618, D22), judged over its + * canonical path, case-insensitively. Any other path is refused; a path that cannot be canonicalised is not a + * test. + */ + public static function isTest(string $file, string $root): bool + { + $canonical = self::canonical($file, $root); + if ($canonical === null || strtolower(self::firstSegment($canonical)) !== 'tests') { + return false; + } + + return is_dir($file) || str_ends_with(strtolower($canonical), '.php'); + } + + /** The first path segment of a canonical path, relative to the house it belongs to (the real house, or the copy that contains it). */ + private static function firstSegment(string $canonical): string + { + foreach (self::COPY_ROOTS as $pattern) { + if (preg_match($pattern, $canonical, $match) === 1) { + $canonical = substr($canonical, \strlen($match[1]) + 1); + break; + } + } + + return explode('/', $canonical)[0]; + } + + /** The name the directory $dir actually holds for the entry that IS $target (same device + inode); null when $dir cannot be listed, the spelling when the entry is gone. */ + private static function storedName(string $dir, string $target): ?string + { + $want = @lstat($target); + if ($want === false) { + return basename($target); + } + $entries = self::entriesOf($dir); + if ($entries === null) { + return null; + } + + return self::matchByIdentity($entries, $want['dev'], $want['ino']) ?? basename($target); + } + + /** + * The entries of a directory as `[name, device, inode]` tuples, or null when it cannot be listed. A LIST, not a + * map keyed by name: a name that is a decimal integer must stay a string, and a PHP array would make it an + * integer key (greenhouse decisions/0618, F-NUM). + * + * @return null|list + */ + private static function entriesOf(string $dir): ?array + { + $names = @scandir($dir); + if ($names === false) { + return null; + } + $entries = []; + foreach ($names as $name) { + if ($name === '.' || $name === '..') { + continue; + } + $have = @lstat($dir . '/' . $name); + if ($have !== false) { + $entries[] = [$name, $have['dev'], $have['ino']]; + } + } + + return $entries; + } + + /** + * The name of the entry whose device AND inode equal ($dev, $ino), or null if none — the one place the identity + * comparison lives, so a test can pin that both numbers count (not the inode alone) without a second disk. The + * name is cast to string because a decimal-integer directory name must not come back as an int (F-NUM). + * + * @internal + * + * @param list $entries name, device, inode + */ + public static function matchByIdentity(array $entries, int $dev, int $ino): ?string + { + foreach ($entries as [$name, $edev, $eino]) { + if ($edev === $dev && $eino === $ino) { + return (string) $name; + } + } + + return null; + } } diff --git a/tests/Operations/SourceReadHandlerTest.php b/tests/Operations/SourceReadHandlerTest.php index 916196b..3b5e4e0 100644 --- a/tests/Operations/SourceReadHandlerTest.php +++ b/tests/Operations/SourceReadHandlerTest.php @@ -16,6 +16,7 @@ use Milpa\DevTools\Operations\SourceReadHandler; use Milpa\DevTools\Support\RootResolver; +use Milpa\DevTools\Support\SecretFiles; use PHPUnit\Framework\TestCase; /** @@ -125,11 +126,12 @@ public static function secretFiles(): iterable } /** - * A COPY OF A SECRET IS A SECRET (greenhouse evidence/1161). A trial under `var/trials//copy/` keeps a - * copy of the envelope; a house that has not discarded its old trials still holds it. A read withholds a - * file whose path ENDS in one of the secret files, at any depth — but not one that merely looks like it. + * A SECRET-NAMED FILE AT ANY DEPTH, IN ANY CASE, IS A SECRET (greenhouse decisions/0618, F-SECRETNEST). The rule + * the house keeps is milpa/app-runtime's — a `.env*` basename (bar templates) and `.milpa/secrets.json` / + * `auth.json`, at any depth, case-insensitively — and a copy of one (a trial, a boot candidate) is a secret too + * (greenhouse evidence/1161). It is asked about the file each path resolves to, so none of these is handed back. * - * @param string $path the relative path of a copy of a secret file + * @param string $path the relative path of a secret file (nested, cased, or in a copy) */ #[\PHPUnit\Framework\Attributes\DataProvider('secretCopies')] public function testACopyOfASecretFileIsNotReadEither(string $path): void @@ -154,10 +156,142 @@ public static function secretCopies(): iterable yield 'a boot candidate copy of the envelope' => ['var/boot-candidates/b1/.milpa/secrets.json']; yield 'an env-local of the house' => ['.env.local']; yield 'an env for production' => ['.env.production']; - yield 'the envelope in upper case' => ['.MILPA/SECRETS.JSON']; - yield 'an env in upper case' => ['.ENV']; - yield 'composer credentials in upper case' => ['AUTH.JSON']; yield 'a trial copy of env-local' => ['var/trials/w1/copy/.env.local']; + // F-SECRETNEST (1): a secret-named file in a SUBDIRECTORY is still a secret. + yield 'a nested env in config' => ['config/.env']; + yield 'a nested env-local in src' => ['src/.env.local']; + yield 'a nested env-production in public' => ['public/.env.production']; + yield 'a nested auth.json in tools' => ['tools/auth.json']; + yield 'a nested envelope under .milpa' => ['sub/.milpa/secrets.json']; + yield 'a deeply nested env' => ['a/b/c/.env']; + yield 'a nested env inside a copy' => ['var/trials/w1/copy/src/.env']; + // F-SECRETNEST (2): the name is matched case-insensitively. + yield 'a capitalized env' => ['.Env.staging']; + yield 'an uppercase envelope' => ['.MILPA/SECRETS.JSON']; + yield 'uppercase composer credentials' => ['AUTH.JSON']; + } + + /** + * THE PUBLISHED NAME RULE OVER THE CALLER'S PATH IS THE FLOOR (greenhouse decisions/0618, F-STOREDFOLD-N). When + * the canonical name resolves to a non-secret alias — here a second hardlink `aaa.json` that sorts first, so the + * stored-name walk picks it — the read is STILL refused, because the published rule is also asked of the path as + * the caller wrote it (`sub/.milpa/secrets.json`). On a folding disk this is the nested secret stored under a + * folded name; the leg is the same. Red on round 5 (canonical only), green now. + */ + public function testASecretIsRefusedByTheCallerSpellingEvenWhenCanonicalResolvesElsewhere(): void + { + @mkdir($this->root . '/sub/.milpa', 0o775, true); + file_put_contents($this->root . '/sub/.milpa/secrets.json', "SECRET=canary\n"); + if (!@link($this->root . '/sub/.milpa/secrets.json', $this->root . '/sub/.milpa/aaa.json')) { + self::markTestSkipped('the filesystem does not support hardlinks'); + } + + $result = $this->handler()->handle(['path' => 'sub/.milpa/secrets.json']); + + self::assertFalse($result['ok'], 'the caller spelled a secret name; it is refused'); + self::assertStringNotContainsString('canary', (string) ($result['error'] ?? '')); + } + + /** + * THE INODE LEG COMPARES DEVICE AND INODE (greenhouse decisions/0618, F-COVER-5 / K3). Pins + * {@see SecretFiles::identityInSet} directly, so a regression comparing the inode NUMBER alone — a file on + * another device with a secret's inode number — is caught without a second disk. + */ + public function testTheInodeLegRequiresTheDeviceToo(): void + { + $set = [[41, 7], [42, 9]]; + self::assertTrue(SecretFiles::identityInSet($set, 41, 7)); + self::assertFalse(SecretFiles::identityInSet($set, 99, 7), 'same inode number, different device, is not a match'); + } + + /** + * A SECRET IS WITHHELD BY WHAT THE FILE IS, NOT ITS NAME (greenhouse decisions/0618, F1-R). A hardlink to the + * real envelope, under an innocent `.php` name in `src/`, shares the envelope's inode and is refused — the + * operator-hardlink that the name-based rule used to hand over. + */ + public function testAHardlinkToASecretIsRefusedByIdentity(): void + { + @mkdir($this->root . '/.milpa', 0o775, true); + file_put_contents($this->root . '/.milpa/secrets.json', "SECRET=canary\n"); + @mkdir($this->root . '/src', 0o775, true); + if (!@link($this->root . '/.milpa/secrets.json', $this->root . '/src/innocent.php')) { + self::markTestSkipped('the filesystem does not support hardlinks'); + } + + $result = $this->handler()->handle(['path' => 'src/innocent.php']); + + self::assertFalse($result['ok'], 'a hardlink to the envelope is the envelope'); + self::assertStringNotContainsString('canary', (string) ($result['error'] ?? '')); + } + + /** + * THE SECRET RULE READS THE STORED NAME, NOT THE CALLER'S SPELLING (greenhouse decisions/0618, mutant N1). A + * second hardlink to a `.env` in the same directory, under an innocent name, resolves by identity to the stored + * `.env` — so the read is refused even on a case-sensitive disk, which pins that `holds()` asks the name rule of + * the canonical path, not the spelling. + */ + public function testASecretReadThroughAnInnocentHardlinkNameIsRefused(): void + { + @mkdir($this->root . '/pkg', 0o775, true); + file_put_contents($this->root . '/pkg/.env', "SECRET=canary\n"); + if (!@link($this->root . '/pkg/.env', $this->root . '/pkg/plain.txt')) { + self::markTestSkipped('the filesystem does not support hardlinks'); + } + + $result = $this->handler()->handle(['path' => 'pkg/plain.txt']); + + self::assertFalse($result['ok'], 'plain.txt is a second name for pkg/.env'); + self::assertStringNotContainsString('canary', (string) ($result['error'] ?? '')); + } + + /** + * THE RULE NEVER NARROWS WHAT THE PUBLISHED RULE REFUSED (greenhouse decisions/0618, F-SECRETNEST). Identity may + * only ADD refusals. Over a tree of secret-named files at several depths, cases, and inside a copy, the set + * `holds()` refuses must contain everything the published milpa/devtools 0.44.2 name rule refused — the oracle + * copied verbatim below. (Red on the round-3 identity rewrite, which narrowed to top-level/exact-case only.) + */ + public function testHoldsRefusesEverythingThePublishedRuleRefused(): void + { + $tree = [ + '.env', '.env.local', '.env.production', 'auth.json', '.milpa/secrets.json', + 'config/.env', 'src/.env.local', 'public/.env.production', 'tools/auth.json', 'sub/.milpa/secrets.json', + 'a/b/c/.env', '.Env.staging', 'config/.ENV', 'TOOLS/AUTH.JSON', + 'var/trials/w1/copy/.env', 'var/trials/w1/copy/src/.env.local', 'var/trials/w1/copy/.milpa/secrets.json', + // not secrets by the published rule — must stay readable, so the new rule must not over-refuse either: + '.env.example', '.env.dist', 'docs/secrets.json', 'src/auth.json.php', 'config/myenv', 'src/App.php', + ]; + foreach ($tree as $rel) { + $full = $this->root . '/' . $rel; + @mkdir(\dirname($full), 0o775, true); + file_put_contents($full, "SECRET=canary\n"); + } + foreach ($tree as $rel) { + $old = self::publishedIsSecret($rel); + $new = SecretFiles::holds((string) realpath($this->root . '/' . $rel), $this->root); + if ($old) { + self::assertTrue($new, "head must still refuse what 0.44.2 refused: {$rel}"); + } else { + self::assertFalse($new, "head must not over-refuse what 0.44.2 read: {$rel}"); + } + } + } + + /** The published milpa/devtools 0.44.2 SecretFiles::isSecret, verbatim, as the set-difference oracle. */ + private static function publishedIsSecret(string $relative): bool + { + $relative = str_replace('\\', '/', $relative); + $base = strtolower(basename($relative)); + if (str_starts_with($base, '.env') && ! \in_array($base, ['.env.example', '.env.dist', '.env.template', '.env.sample'], true)) { + return true; + } + $low = strtolower($relative); + foreach (['.milpa/secrets.json', 'auth.json'] as $tail) { + if ($low === $tail || str_ends_with($low, '/' . $tail)) { + return true; + } + } + + return false; } /** @@ -194,6 +328,86 @@ public function testASourceFileNamedLikeNoSecretStillReads(): void self::assertTrue($result['ok'], 'only the house\'s own secret files are withheld, by their exact path'); } + /** + * SOURCE:READ READS SOURCE, NOT THE RUNNING HOUSE'S STATE (greenhouse decisions/0618). `var/` and + * `storage/` are what the house writes as it runs — the passkey session store (a live cookie), the HTTP + * confirm tokens, the enrollment ledger, the agent's session log, trial copies. A read of source is not + * the door to any of it, even for a file that is not itself a named secret. + * + * @param string $path a non-secret file under the house's runtime state + */ + #[\PHPUnit\Framework\Attributes\DataProvider('houseState')] + public function testTheRunningHousesStateIsNotReadAsSource(string $path): void + { + $full = $this->root . '/' . $path; + @mkdir(\dirname($full), 0o775, true); + file_put_contents($full, "{\"canary\":\"value\"}\n"); + + $result = $this->handler()->handle(['path' => $path]); + + self::assertFalse($result['ok'], $path); + self::assertArrayNotHasKey('content', $result); + self::assertStringNotContainsString('canary', (string) ($result['error'] ?? ''), 'the refusal never carries the value'); + } + + /** @return iterable */ + public static function houseState(): iterable + { + yield 'the passkey session store' => ['var/passkey/sessions.json']; + yield 'the HTTP confirm tokens' => ['storage/confirm-tokens.json']; + yield 'the enrollment ledger' => ['storage/identity/enrollments.json']; + yield 'the token store' => ['storage/tokens.json']; + yield 'the agent session log' => ['var/agent-sessions.jsonl']; + yield 'an invitation ledger' => ['storage/identity/invitations.json']; + // D20: a copy's OWN state is still state — refused, judged relative to the copy's root. + yield 'a trial copy own session store' => ['var/trials/w1/copy/var/passkey/sessions.json']; + yield 'a trial copy own confirm tokens' => ['var/trials/w1/copy/storage/confirm-tokens.json']; + yield 'a boot candidate own var' => ['var/boot-candidates/b1/var/passkey/sessions.json']; + // A copy nested in a copy reads as the outer copy's var state — refused. + yield 'a copy inside a copy' => ['var/trials/w1/copy/var/trials/w2/copy/src/Deep.php']; + } + + /** + * A COPY OF THE HOUSE IS READ LIKE A HOUSE: ITS SOURCE YES, ITS STATE NO (greenhouse decisions/0618, D20). + * + * The agent reads the source of its own trial copy to work on it — the one house-state read it actually makes + * (greenhouse evidence/1199). A trial, an exercise and a boot candidate are copies: their `src/`, `tests/`, + * `config/` are source and read; their own `var/`/`storage/` is state and refused (covered above). + * + * @param string $path a source file inside a copy of the house + */ + #[\PHPUnit\Framework\Attributes\DataProvider('copySource')] + public function testACopyOfTheHouseIsReadLikeAHouse(string $path): void + { + $full = $this->root . '/' . $path; + @mkdir(\dirname($full), 0o775, true); + file_put_contents($full, "handler()->handle(['path' => $path])['ok'], $path); + } + + /** @return iterable */ + public static function copySource(): iterable + { + yield 'a trial copy src' => ['var/trials/w1/copy/src/Sample.php']; + yield 'a trial copy test' => ['var/trials/w1/copy/tests/SampleTest.php']; + yield 'a trial copy config' => ['var/trials/w1/copy/config/app.php']; + yield 'an exercise copy src' => ['var/exercises/e1/copy/src/Sample.php']; + yield 'a boot candidate src' => ['var/boot-candidates/b1/src/Sample.php']; + } + + public function testADirectoryThatMerelyLooksLikeStateStillReads(): void + { + // Only `var/` and `storage/` at the root are the house's state; a source file whose name contains + // "var" or "storage" elsewhere is read as always. + foreach (['src/VarExporter.php', 'src/Storage/Driver.php', 'docs/storage.md'] as $path) { + $full = $this->root . '/' . $path; + @mkdir(\dirname($full), 0o775, true); + file_put_contents($full, "source\n"); + self::assertTrue($this->handler()->handle(['path' => $path])['ok'], $path); + } + } + public function testAMissingPathInputIsAnswered(): void { $result = $this->handler()->handle([]); diff --git a/tests/Operations/TestHandlerTest.php b/tests/Operations/TestHandlerTest.php index 2c351ed..2c1633d 100644 --- a/tests/Operations/TestHandlerTest.php +++ b/tests/Operations/TestHandlerTest.php @@ -238,6 +238,27 @@ public function testAPathThatDoesNotExistIsRefused(): void self::assertFalse($r['ran']); } + /** + * A HARDLINK UNDER tests/ TO A SECRET IS NOT RUN (greenhouse decisions/0618, F3). `test` includes the path + * through PHPUnit, which would echo a non-PHP secret; it asks the same identity question the readers ask + * ({@see SecretFiles}), so a `.php`-named hardlink to the house's `.env` is refused and never handed to PHPUnit. + */ + public function testAHardlinkUnderTestsToASecretIsNotRun(): void + { + file_put_contents($this->raiz . '/.env', "SECRET=canary\n"); + if (!@link($this->raiz . '/.env', $this->raiz . '/tests/Sneaky.php')) { + self::markTestSkipped('the filesystem does not support hardlinks'); + } + $doble = $this->doble(['exit' => 0, 'output' => 'OK (1 test, 1 assertion)']); + + $r = (new TestHandler($this->roots(), $doble))->handle(['path' => 'tests/Sneaky.php']); + + self::assertFalse($r['ok']); + self::assertFalse($r['ran']); + self::assertNull($doble->comando, 'the secret is never handed to PHPUnit'); + self::assertStringNotContainsString('canary', (string) $r['error']); + } + /** * La salida se recorta conservando el FINAL, y se dice que se recortó. * @@ -269,4 +290,59 @@ public function testTheTimeoutIsClampedToSomethingMeaningful(): void $handler->handle([]); self::assertSame(300, $doble->plazo, 'sin decir nada, cinco minutos'); } + + /** + * `test` RUNS TESTS, AND RETURNS NOTHING THAT IS NOT ONE (greenhouse decisions/0618, D22). + * + * PHPUnit includes the path, so a non-PHP file would be echoed and its bytes handed back. A path that is not + * a test — the env file, the envelope, a store under `var/`/`storage/`, any other source — is refused BEFORE + * the command is armed; a real test (the house's, or a trial copy's) still runs. + * + * @param string $path a path that is not a test + */ + #[\PHPUnit\Framework\Attributes\DataProvider('nonTestPaths')] + public function testANonTestPathIsRefusedBeforeTheCommand(string $path): void + { + $full = $this->raiz . '/' . $path; + @mkdir(\dirname($full), 0o777, true); + file_put_contents($full, "SECRET=canary\n"); + + $doble = $this->doble(['exit' => 0, 'output' => 'OK (1 test, 1 assertion)']); + $r = (new TestHandler($this->roots(), $doble))->handle(['path' => $path]); + + self::assertFalse($r['ok'], $path); + self::assertFalse($r['ran'], $path); + self::assertNull($doble->comando, 'nothing was ever run, so nothing was echoed'); + self::assertStringContainsString('must be a test under tests/', (string) $r['error']); + self::assertStringNotContainsString('canary', (string) $r['error'], 'the refusal never carries the value'); + } + + /** @return iterable */ + public static function nonTestPaths(): iterable + { + yield 'the env file' => ['.env']; + yield 'the envelope' => ['.milpa/secrets.json']; + yield 'the confirm tokens' => ['storage/confirm-tokens.json']; + yield 'the session store' => ['var/passkey/sessions.json']; + yield 'ordinary source' => ['src/Http/Thing.php']; + yield 'a trial copy env' => ['var/trials/w1/copy/.env']; + yield 'a trial copy source' => ['var/trials/w1/copy/src/Sample.php']; + // D22/F2: a non-.php file left under tests/ is not a test — PHPUnit must not be handed it to include. + yield 'a non-php file under tests' => ['tests/notphp.txt']; + } + + /** A real test runs — the house's own, and a trial copy's (greenhouse decisions/0618, D20/D22). */ + public function testARealTestRunsEvenInsideATrialCopy(): void + { + $copyTest = $this->raiz . '/var/trials/w1/copy/tests/Plugins/Hello/CopyTest.php'; + @mkdir(\dirname($copyTest), 0o777, true); + file_put_contents($copyTest, 'doble(['exit' => 0, 'output' => 'OK (1 test, 1 assertion)']); + $r = (new TestHandler($this->roots(), $doble))->handle(['path' => $path]); + self::assertTrue($r['ran'], $path); + self::assertContains($this->raiz . '/' . $path, (array) $doble->comando, $path); + } + } } diff --git a/tests/Support/SourcePathTest.php b/tests/Support/SourcePathTest.php new file mode 100644 index 0000000..37a5666 --- /dev/null +++ b/tests/Support/SourcePathTest.php @@ -0,0 +1,273 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/devtools + */ + +declare(strict_types=1); + +namespace Milpa\DevTools\Tests\Support; + +use Milpa\DevTools\Support\SourcePath; +use PHPUnit\Framework\TestCase; + +/** + * ONE RULE, FOR BOTH READERS: WHICH HOUSE IS THIS PATH FROM, AND IS IT SOURCE OR STATE? (greenhouse decisions/0618.) + * + * `source:read`/`source:page` refuse a house's STATE and read its source; `test` runs only a house's TESTS. A path + * inside a COPY of the house (a trial, an exercise, a boot candidate) is judged relative to that copy — its source is + * source, its own `var/`/`storage/` is state. The rule reads the file each path RESOLVES to (its stored-name path by + * device + inode), so a folded spelling gets one answer; a path whose directory cannot be listed fails CLOSED. The + * cases plant real files, because the identity rule asks the filesystem what each path is. + */ +final class SourcePathTest extends TestCase +{ + private string $root; + + protected function setUp(): void + { + $this->root = sys_get_temp_dir() . '/milpa-sp-' . bin2hex(random_bytes(5)); + @mkdir($this->root, 0o775, true); + } + + protected function tearDown(): void + { + self::removeTree($this->root); + } + + private function plant(string $relative, bool $dir = false): string + { + $full = $this->root . '/' . $relative; + @mkdir($dir ? $full : \dirname($full), 0o775, true); + if (! $dir && ! file_exists($full)) { + file_put_contents($full, "x\n"); + } + + return $full; + } + + /** + * @param bool $expected whether the resolved path is the running house's STATE (D20) + */ + #[\PHPUnit\Framework\Attributes\DataProvider('houseStateCases')] + public function testIsHouseState(string $path, bool $expected): void + { + $this->plant($path); + self::assertSame($expected, SourcePath::isHouseState($this->root . '/' . $path, $this->root), $path); + } + + /** @return iterable */ + public static function houseStateCases(): iterable + { + yield 'the session store' => ['var/passkey/sessions.json', true]; + yield 'the confirm tokens' => ['storage/confirm-tokens.json', true]; + yield 'a log under var' => ['var/coa-tui.log', true]; + yield 'the real house source' => ['src/Http/IdentityChain.php', false]; + yield 'the real house config' => ['config/app.php', false]; + yield 'the real house tests' => ['tests/SomeTest.php', false]; + // D20: a trial copy is a house of its own — its source is source, its own state is state. + yield 'a trial copy source' => ['var/trials/w1/copy/src/Sample.php', false]; + yield 'a trial copy test' => ['var/trials/w1/copy/tests/SampleTest.php', false]; + yield 'a trial copy own var' => ['var/trials/w1/copy/var/passkey/sessions.json', true]; + yield 'a trial copy own storage' => ['var/trials/w1/copy/storage/confirm-tokens.json', true]; + yield 'an exercise copy source' => ['var/exercises/e1/copy/src/Sample.php', false]; + yield 'a boot candidate source' => ['var/boot-candidates/b1/src/Sample.php', false]; + yield 'a boot candidate own var' => ['var/boot-candidates/b1/var/passkey/sessions.json', true]; + // A copy nested inside a copy strips only the outer root -> reads as the outer copy's var state (refused). + yield 'a copy inside a copy' => ['var/trials/w1/copy/var/trials/w2/copy/src/Deep.php', true]; + // A directory merely NAMED copy, not a trial copy, is ordinary source. + yield 'a source dir named copy' => ['src/copy/Real.php', false]; + yield 'a var dir named copy (not a trial)' => ['var/copy/thing.json', true]; + } + + /** + * File-path cases of «is this a test?» — a file must resolve to a `.php` under a `tests/` of its house or a copy. + * + * @param bool $expected whether the resolved path is a TEST of the house it belongs to (D22) + */ + #[\PHPUnit\Framework\Attributes\DataProvider('fileCases')] + public function testIsTestForFiles(string $path, bool $expected): void + { + $this->plant($path); + self::assertSame($expected, SourcePath::isTest($this->root . '/' . $path, $this->root), $path); + } + + /** @return iterable */ + public static function fileCases(): iterable + { + yield 'a house test file' => ['tests/SampleTest.php', true]; + yield 'a trial copy test' => ['var/trials/w1/copy/tests/SampleTest.php', true]; + // F2: a non-`.php` file left under tests/ is NOT a test — it must not be handed to PHPUnit to include. + yield 'a non-php file under tests' => ['tests/notphp.txt', false]; + // F2-MUT: a name that CONTAINS but does not END in .php is not a test (pins str_ends_with vs str_contains). + yield 'a .php.bak backup under tests' => ['tests/SampleTest.php.bak', false]; + yield 'a .phpx file under tests' => ['tests/Weird.phpx', false]; + yield 'the env file' => ['.env', false]; + yield 'the envelope' => ['.milpa/secrets.json', false]; + yield 'the session store' => ['var/passkey/sessions.json', false]; + yield 'the confirm tokens' => ['storage/confirm-tokens.json', false]; + yield 'ordinary source' => ['src/Http/IdentityChain.php', false]; + yield 'a trial copy source' => ['var/trials/w1/copy/src/Sample.php', false]; + yield 'a dir named tests in src' => ['src/tests/Helper.php', false]; + } + + /** + * A DIRECTORY under `tests/` is a test target (PHPUnit discovers its `*.php` tests and ignores the rest), so it + * runs — the house's, and a copy's. + */ + public function testATestDirectoryRuns(): void + { + $this->plant('tests/Plugins/Hello', dir: true); + $this->plant('var/trials/w1/copy/tests/Plugins/Hello', dir: true); + $this->plant('tests/Plugins/Hello/leftover.txt'); + + self::assertTrue(SourcePath::isTest($this->root . '/tests', $this->root), 'the house tests/ dir'); + self::assertTrue(SourcePath::isTest($this->root . '/tests/Plugins/Hello', $this->root), 'a plugin test dir'); + self::assertTrue(SourcePath::isTest($this->root . '/var/trials/w1/copy/tests/Plugins/Hello', $this->root), 'a copy test dir'); + self::assertFalse(SourcePath::isTest($this->root . '/tests/Plugins/Hello/leftover.txt', $this->root), 'a non-php file is not a test'); + } + + /** + * THE CANONICAL PATH IS THE STORED NAME, NOT THE SPELLING (greenhouse decisions/0618). Two hardlinks of one + * inode sit side by side; the directory holds both names but the file IS one entry. `canonical` returns the + * name the directory actually lists for that inode — pinning that the stored-name walk runs (MUT1) and that it + * does not fall back to the caller's basename (MUT3), on a plain case-sensitive disk. + */ + public function testCanonicalReturnsTheStoredNameOfAHardlinkedPair(): void + { + $this->plant('pkg', dir: true); + $first = $this->root . '/pkg/a-first.php'; + $second = $this->root . '/pkg/z-second.php'; + file_put_contents($first, "root), 'the stored name, not the spelling'); + } + + /** + * BOTH DEVICE AND INODE DECIDE WHICH ENTRY A FILE IS (greenhouse decisions/0618). Pins {@see SourcePath::matchByIdentity} + * directly, so a regression that compares the inode alone (ignoring the device) is caught without a second disk: + * a decoy sharing the inode but on another device must NOT win. A LIST of `[name, dev, ino]`, so an entry whose + * name is a decimal integer stays a string (F-NUM). + */ + public function testMatchByIdentityRequiresTheDeviceToo(): void + { + $entries = [ + ['decoy-same-inode-other-device', 41, 7], + ['the-file', 42, 7], + ['unrelated', 42, 9], + ['2024', 42, 11], + ]; + self::assertSame('the-file', SourcePath::matchByIdentity($entries, 42, 7)); + self::assertNull(SourcePath::matchByIdentity($entries, 99, 7), 'no entry on device 99'); + self::assertSame('2024', SourcePath::matchByIdentity($entries, 42, 11), 'a decimal-integer name stays a string'); + } + + /** + * AN INTEGER-NAMED DIRECTORY MUST NOT CRASH THE WALK (greenhouse decisions/0618, F-NUM). A decimal name + * (`2024`, `0`, `007`) would become an integer array key in PHP; the walk uses a list, so these read normally. + * + * @param string $path a path under a directory whose name is numeric-looking + */ + #[\PHPUnit\Framework\Attributes\DataProvider('numericDirCases')] + public function testANumericDirectoryNameDoesNotCrashTheWalk(string $path, bool $state): void + { + $this->plant($path); + // The point is that no TypeError is thrown; the classification is sane too. + self::assertSame($state, SourcePath::isHouseState($this->root . '/' . $path, $this->root), $path); + self::assertIsString(SourcePath::canonical($this->root . '/' . $path, $this->root), $path); + } + + /** @return iterable */ + public static function numericDirCases(): iterable + { + yield 'a year folder in src' => ['src/2024/Note.php', false]; + yield 'a numeric id under tests' => ['tests/2025/SomeTest.php', false]; + yield 'a zero-named dir' => ['src/0/Zero.php', false]; + yield 'an octal-looking dir' => ['src/007/Bond.php', false]; + yield 'a float-looking dir' => ['src/1e3/Sci.php', false]; + yield 'a negative-looking dir' => ['src/-1/Neg.php', false]; + yield 'a numeric dir under var' => ['var/2024/cache.json', true]; + } + + /** + * THE STATE RULE MATCHES THE SEGMENT CASE-INSENSITIVELY, LIKE THE SECRET RULE (greenhouse decisions/0618, + * F-STOREDFOLD (b)). A stored `Storage/` or `Var/` is the house's state by any case — the same comparison all + * three questions use, so none is a case asymmetry. + */ + public function testTheStateRuleIsCaseInsensitiveOnTheStoredSegment(): void + { + $this->plant('Storage/confirm-tokens.json'); + $this->plant('VAR/passkey/sessions.json'); + if (realpath($this->root . '/Storage') === realpath($this->root . '/storage')) { + // a folding disk already made them the real dir; the point still holds, assert it anyway. + } + self::assertTrue(SourcePath::isHouseState($this->root . '/Storage/confirm-tokens.json', $this->root), 'Storage/ is state'); + self::assertTrue(SourcePath::isHouseState($this->root . '/VAR/passkey/sessions.json', $this->root), 'VAR/ is state'); + } + + /** + * THE TEST SEGMENT IS MATCHED CASE-INSENSITIVELY (greenhouse decisions/0618, F-COVER-5 / K6), the same comparison + * the secret and state rules use. A `.php` file under a stored `Tests/` runs; a regression that compared the + * segment case-sensitively would refuse a real test (loud). + */ + public function testTheTestSegmentIsCaseInsensitive(): void + { + $this->plant('Tests/SampleTest.php'); + if (realpath($this->root . '/Tests') === realpath($this->root . '/tests')) { + // a folding disk already made them one dir; the assertion still holds. + } + self::assertTrue(SourcePath::isTest($this->root . '/Tests/SampleTest.php', $this->root), 'a .php under Tests/ is a test'); + } + + /** + * A PATH WHOSE DIRECTORY CANNOT BE LISTED FAILS CLOSED (greenhouse decisions/0618, F-SECRETNEST (3)). An + * operator mode that allows traverse but not list (`0311`) leaves the file openable yet its identity + * unverifiable; the rules then withhold — state, not source; not a test. + */ + public function testAnUnlistableDirectoryIsTreatedAsState(): void + { + if (\function_exists('posix_geteuid') && posix_geteuid() === 0) { + self::markTestSkipped('root ignores directory permission bits'); + } + $this->plant('sealed/app.php'); + $this->plant('sealed/tests', dir: true); + $this->plant('sealed/tests/SomeTest.php'); + @chmod($this->root . '/sealed', 0o311); // --x: traverse, not list + try { + if (@scandir($this->root . '/sealed') !== false) { + self::markTestSkipped('this environment can still list a 0311 directory'); + } + self::assertTrue(SourcePath::isHouseState($this->root . '/sealed/app.php', $this->root), 'unlistable -> state'); + self::assertFalse(SourcePath::isTest($this->root . '/sealed/tests/SomeTest.php', $this->root), 'unlistable -> not a test'); + self::assertNull(SourcePath::canonical($this->root . '/sealed/app.php', $this->root), 'unlistable -> no canonical path'); + } finally { + @chmod($this->root . '/sealed', 0o775); + } + } + + private static function removeTree(string $root): void + { + if (! is_dir($root)) { + return; + } + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($it as $f) { + @chmod($f->getPathname(), 0o775); + $f->isDir() ? @rmdir($f->getPathname()) : @unlink($f->getPathname()); + } + @rmdir($root); + } +}