diff --git a/CAPABILITIES.md b/CAPABILITIES.md index ab70f0a6..37171046 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**229 classes** across **33 capabilities** · **25 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**240 classes** across **33 capabilities** · **26 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -120,6 +120,8 @@ - **Tiger_License_Store_Option** `@api` — the default license store, backed by the lazy `option` tier. · `library/Tiger/License/Store/Option.php` - **Tiger_License_Vendor** `@api` — CONNECT + pin a paid module's vendor (its `[owner]/TigerVendor` repo). · `library/Tiger/License/Vendor.php` - **Tiger_Model_Module** `@api` — the module lifecycle registry (see migration 0023). · `library/Tiger/Model/Module.php` +- **Tiger_Model_ModuleInstallPin** `@api` — a consumer install's version policy + rollback anchor (TIGER-334, 0054). · `library/Tiger/Model/ModuleInstallPin.php` +- **Tiger_Model_ModuleRelease** `@api` — a module release moving through QA (TIGER-334, migration 0054). · `library/Tiger/Model/ModuleRelease.php` - **Tiger_Model_UpdateHistory** `@api` — the durable log of one-click update runs (table `update_history`). · `library/Tiger/Model/UpdateHistory.php` - **Tiger_Module_Compat** `@api` — advisory "which Tiger versions was this module tested for?" metadata. · `library/Tiger/Module/Compat.php` - **Tiger_Module_Dependency** `@api` — lightweight, lazy inter-module dependency alerts. · `library/Tiger/Module/Dependency.php` @@ -306,6 +308,9 @@ - **Tiger_Model_AgentRun** `@api` — one turn's execution + control record (see migration 0036). · `library/Tiger/Model/AgentRun.php` - **Tiger_Model_Comment** `@api` — the one comment/review store (migration 0045). · `library/Tiger/Model/Comment.php` - **Tiger_Model_CommentAggregate** `@api` — the denormalized per-subject rollup (migration 0046). · `library/Tiger/Model/CommentAggregate.php` +- **Tiger_Model_GitCredential** `@api` — a least-privilege, encrypted-at-rest credential (TIGER-334, migration 0054). · `library/Tiger/Model/GitCredential.php` +- **Tiger_Model_GitOperationLog** `@api` — the append-only git audit trail (TIGER-334, migration 0054). · `library/Tiger/Model/GitOperationLog.php` +- **Tiger_Model_GitRepoConnection** `@api` — a module's connection to its GitHub repo (TIGER-334, migration 0054). · `library/Tiger/Model/GitRepoConnection.php` - **Tiger_Model_Menu** `@api` — Menu — custom navigation menus (see migration 0017). · `library/Tiger/Model/Menu.php` - **Tiger_Model_Message** `@api` — the message body store (migration 0047, TIGER-114). · `library/Tiger/Model/Message.php` - **Tiger_Model_MessageBlock** `@api` — one user muting another, within an org (migration 0049). · `library/Tiger/Model/MessageBlock.php` @@ -319,6 +324,12 @@ - **Tiger_Comment** `@api` — the subject registry and policy gate for comments, ratings and reviews. · `library/Tiger/Comment.php` - **Tiger_Comment_Spam** `@api` — the spam-check registry, and the first checker: the in-platform AI agent. · `library/Tiger/Comment/Spam.php` +- **Tiger_Git** `@api` — the facade + shared constants for the platform's one git/GitHub layer (TIGER-334). · `library/Tiger/Git.php` +- **Tiger_Git_Credential** `@api` — the write-only credential manager (TIGER-334 / TIGER-365). · `library/Tiger/Git/Credential.php` +- **Tiger_Git_Environment** `@api` — detect which transport this instance can use (TIGER-334). · `library/Tiger/Git/Environment.php` +- **Tiger_Git_Exception** `@api` — a structured, agent-readable, SECRET-FREE git failure (TIGER-334). · `library/Tiger/Git/Exception.php` +- **Tiger_Git_Transport_Interface** `@api` — HOW a git operation is carried out (TIGER-334). · `library/Tiger/Git/Transport/Interface.php` +- **Tiger_Git_WorkingTree** `@api` — one module's git working tree (TIGER-334). · `library/Tiger/Git/WorkingTree.php` - **Tiger_Message** `@api` — the platform's way of telling people things (TIGER-114). · `library/Tiger/Message.php` - **Tiger_Secret** `@api` — provider-neutral resolution of secrets and sensitive config values. · `library/Tiger/Secret.php` - **Tiger_Secret_Adapter_Chain** `@api` — try several backends in order; the first hit wins. · `library/Tiger/Secret/Adapter/Chain.php` @@ -348,6 +359,7 @@ - **CMS** (`cms`, app) · services: Menu, Page, Paths, Settings · `modules/cms` - **Code** (`code`, developer) · services: Code · `modules/code` - **Comments** (`comment`, app) · services: Comment, Render, Settings, Subjects · `modules/comment` +- **Git** (`git`, app) · `modules/git` - **Identity** (`identity`, plugin) · services: Identity · `modules/identity` - **Matomo** (`matomo`, app) · services: Matomo · `modules/matomo` - **Mcp** (`mcp`, module) · services: Settings · `modules/mcp` diff --git a/library/Tiger/Git.php b/library/Tiger/Git.php new file mode 100644 index 00000000..e7af63ff --- /dev/null +++ b/library/Tiger/Git.php @@ -0,0 +1,112 @@ +://…`; anything else is a local git_credential id. */ + private static function isSecretRef(string $ref): bool + { + return (bool) preg_match('~^[a-z][a-z0-9+.-]*://~', $ref); + } + + /** + * Store a new credential, encrypted. Returns the SAFE public view (NO secret). The caller then + * records the returned `ref` on the connection. + * + * @param string $orgId + * @param string $moduleKey + * @param string $type Tiger_Model_GitCredential::TYPE_TOKEN|TYPE_DEPLOY_KEY + * @param string $secret the plaintext token or private key (consumed, never returned) + * @param array $meta public_material?, fingerprint?, scopes? + * @return array{ref:string, public:array} + */ + public function store(string $orgId, string $moduleKey, string $type, string $secret, array $meta = []): array + { + if ($secret === '') { + throw new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_MISSING, 'a credential value is required'); + } + if (!Tiger_Crypto::isConfigured()) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::CREDENTIAL_INVALID, 'encryption is not configured on this install'); + } + $model = new Tiger_Model_GitCredential(); + $id = (string) $model->insert([ + 'org_id' => $orgId, + 'module_key' => $moduleKey, + 'type' => $type, + 'ciphertext' => Tiger_Crypto::encrypt($secret), + 'public_material' => $meta['public_material'] ?? null, + 'fingerprint' => $meta['fingerprint'] ?? null, + 'scopes' => $meta['scopes'] ?? null, + 'status' => 'active', + ]); + sodium_memzero($secret); + $row = $model->findById($id); + return ['ref' => $id, 'public' => Tiger_Model_GitCredential::publicView($row)]; + } + + /** + * Resolve a credential reference to its PLAINTEXT secret, for a transport about to make a request. + * The ONLY read path. Returns null when the ref is unknown/revoked/unusable (the transport then + * marks the connection degraded). NEVER call this from a controller or log its result. + * + * @param string $ref a git_credential id or a Tiger_Secret ref + * @return string|null the plaintext secret, or null + * @internal for transports only + */ + public function resolve(string $ref): ?string + { + if ($ref === '') { return null; } + if (self::isSecretRef($ref)) { + $v = Tiger_Secret::get($ref); + return ($v === null || $v === '') ? null : $v; + } + $row = (new Tiger_Model_GitCredential())->findById($ref); + if (!$row || $row->revoked_at !== null) { return null; } + try { + return Tiger_Crypto::decrypt((string) $row->ciphertext); + } catch (\Throwable $e) { + return null; // unusable secret — treated as "not resolvable", never surfaced + } + } + + /** + * Rotate: store the new credential, let the caller VERIFY it works, then revoke the old one. The + * old secret is only revoked after the new one is recorded, so a rotation never leaves the owner + * with no working credential. Returns the new ref + public view; the caller verifies, then calls + * `revoke($oldRef)` once verification passes. + * + * @return array{ref:string, public:array} + */ + public function rotate(string $orgId, string $moduleKey, string $type, string $newSecret, array $meta = []): array + { + return $this->store($orgId, $moduleKey, $type, $newSecret, $meta); + } + + /** + * Revoke a local credential (mark revoked + soft-delete so the ciphertext stops resolving). A + * Tiger_Secret ref is not ours to revoke — the caller removes it at its authority. + * + * @return bool true if a local credential was revoked + */ + public function revoke(string $ref): bool + { + if ($ref === '' || self::isSecretRef($ref)) { return false; } + $model = new Tiger_Model_GitCredential(); + $row = $model->findById($ref); + if (!$row) { return false; } + $row->revoked_at = gmdate('Y-m-d H:i:s'); + $row->status = 'revoked'; + $row->save(); + $model->softDelete($model->getAdapter()->quoteInto('git_credential_id = ?', $ref)); + return true; + } + + /** + * Generate an Ed25519 deploy keypair (for the ShellTransport path). Returns the OpenSSH PUBLIC key + * line to show the owner (they paste it into the repo's deploy-key box) and its fingerprint; the + * returned `secret` is the private material to hand straight to `store()` and then forget. The + * private key is NEVER returned to any API surface. + * + * @param string $comment a label embedded in the public key (e.g. "tiger:my_module") + * @return array{public_openssh:string, fingerprint:string, secret:string} + */ + public function generateDeployKey(string $comment = 'tiger-git'): array + { + $pair = sodium_crypto_sign_keypair(); + $pub = sodium_crypto_sign_publickey($pair); // 32 bytes ed25519 public + $secret = sodium_crypto_sign_secretkey($pair); // 64 bytes (seed+public) + + // OpenSSH public-key wire format: string "ssh-ed25519" + string <32-byte key>, base64'd. + $blob = self::sshString('ssh-ed25519') . self::sshString($pub); + $publicLine = 'ssh-ed25519 ' . base64_encode($blob) . ' ' . $comment; + // Fingerprint is SHA256 of the blob, base64 (no padding) — the `SHA256:…` GitHub shows. + $fingerprint = 'SHA256:' . rtrim(base64_encode(hash('sha256', $blob, true)), '='); + + return [ + 'public_openssh' => $publicLine, + 'fingerprint' => $fingerprint, + 'secret' => base64_encode($secret), // stored encrypted; the shell path unwraps it + ]; + } + + /** SSH wire "string": 4-byte big-endian length prefix + bytes. */ + private static function sshString(string $s): string + { + return pack('N', strlen($s)) . $s; + } +} diff --git a/library/Tiger/Git/Environment.php b/library/Tiger/Git/Environment.php new file mode 100644 index 00000000..1fe05b74 --- /dev/null +++ b/library/Tiger/Git/Environment.php @@ -0,0 +1,120 @@ + $gitBinary, + 'exec_usable' => $execUsable, + 'outbound' => $outbound, + 'transport' => $transport, + 'usable' => $usable, + 'remediation' => $remediation, + 'git_version' => $gitVersion, + ]; + } + + /** Are proc_open AND exec-family calls actually callable here (not disabled)? */ + public static function execUsable(): bool + { + if (!function_exists('proc_open')) { return false; } + $disabled = array_filter(array_map('trim', explode(',', (string) ini_get('disable_functions')))); + return !in_array('proc_open', $disabled, true); + } + + /** `git --version` string when the binary runs, else null. */ + public static function gitVersion(): ?string + { + if (!self::execUsable()) { return null; } + $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $proc = @proc_open(['git', '--version'], $descriptors, $pipes); + if (!is_resource($proc)) { return null; } + $out = stream_get_contents($pipes[1]) ?: ''; + foreach ($pipes as $p) { if (is_resource($p)) { fclose($p); } } + $exit = proc_close($proc); + if ($exit !== 0) { return null; } + return preg_match('/\d+\.\d+(\.\d+)?/', $out, $m) ? $m[0] : trim($out); + } + + /** Is $host reachable over HTTPS right now? Behind the test seam; the real path is a cheap cURL HEAD. */ + public static function outboundReachable(string $host): bool + { + if (!Tiger_Git::isAllowedHost($host)) { return false; } + if (self::$probe !== null) { + try { return (bool) (self::$probe)($host); } catch (\Throwable $e) { return false; } + } + if (!function_exists('curl_init')) { return false; } + $ch = curl_init('https://' . $host); + curl_setopt_array($ch, [ + CURLOPT_NOBODY => true, + CURLOPT_TIMEOUT => 6, + CURLOPT_CONNECTTIMEOUT => 4, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_USERAGENT => 'Tiger-Git', + ]); + curl_exec($ch); + $errno = curl_errno($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + return $errno === 0 && $code > 0; + } +} diff --git a/library/Tiger/Git/Exception.php b/library/Tiger/Git/Exception.php new file mode 100644 index 00000000..e0c2e580 --- /dev/null +++ b/library/Tiger/Git/Exception.php @@ -0,0 +1,63 @@ + safe extra context (no secrets) */ + protected $detail; + + /** + * @param string $code one of the class constants + * @param string $message a short, secret-free human sentence + * @param array $detail safe structured context (conflicting files, http status, …) + */ + public function __construct(string $code, string $message = '', array $detail = []) + { + parent::__construct($message !== '' ? $message : $code); + $this->gitCode = $code; + $this->detail = $detail; + } + + /** The stable error code (Exception::getCode() is final, so this is the accessor). */ + public function code(): string + { + return $this->gitCode; + } + + /** Safe structured context. @return array */ + public function getDetail(): array + { + return $this->detail; + } +} diff --git a/library/Tiger/Git/Transport/Interface.php b/library/Tiger/Git/Transport/Interface.php new file mode 100644 index 00000000..7d2a462a --- /dev/null +++ b/library/Tiger/Git/Transport/Interface.php @@ -0,0 +1,100 @@ +, clean:bool} + */ + public function status(Tiger_Git_WorkingTree $tree): array; + + /** + * A size-capped diff summary for the working tree (never full file bodies beyond the cap). + * + * @param array $opts {paths?:string[], max_bytes?:int} + * @return array{files:array, truncated:bool} + */ + public function diff(Tiger_Git_WorkingTree $tree, array $opts = []): array; + + /** + * Commit the given paths (relative to the module dir; empty = all tracked changes) on the current + * branch and return the new commit SHA. + * + * @param string $message the commit message + * @param string[] $paths module-relative paths, or [] for all changes + * @return string the new commit SHA + */ + public function commit(Tiger_Git_WorkingTree $tree, string $message, array $paths = []): string; + + /** Local branch names. @return string[] */ + public function branches(Tiger_Git_WorkingTree $tree): array; + + /** Create a branch off the current HEAD (never a protected/default branch name). */ + public function createBranch(Tiger_Git_WorkingTree $tree, string $name): void; + + /** Switch the working tree to an existing branch (refuses on a dirty tree). */ + public function switchBranch(Tiger_Git_WorkingTree $tree, string $name): void; + + /** + * Push the current branch to the remote. Fast-forward only — a non-fast-forward is reported, never + * forced. + * + * @return array{ref:string, pushed:bool} + */ + public function push(Tiger_Git_WorkingTree $tree, string $branch): array; + + /** + * Fast-forward pull of $branch. A non-fast-forward (divergence) or a merge conflict aborts cleanly, + * leaves the working tree unchanged, and is reported. + * + * @return array{ref:string, updated:bool, conflicts:string[]} + */ + public function pull(Tiger_Git_WorkingTree $tree, string $branch): array; + + /** + * Create an annotated tag at $ref (default: current HEAD of the default branch). Semver is enforced + * by the caller; a submitted tag is immutable (never moved or deleted). + * + * @return array{tag:string, sha:string} + */ + public function tag(Tiger_Git_WorkingTree $tree, string $tag, ?string $ref = null): array; + + /** + * Releases on the remote, newest first. + * + * @return array + */ + public function listReleases(Tiger_Git_WorkingTree $tree): array; + + /** + * Download a release archive (the tag's tree) to $destFile and return its path. The ONLY artifact + * an update ever installs — never an arbitrary branch. + * + * @return string the written file path + */ + public function fetchArchive(Tiger_Git_WorkingTree $tree, string $tag, string $destFile): string; +} diff --git a/library/Tiger/Git/WorkingTree.php b/library/Tiger/Git/WorkingTree.php new file mode 100644 index 00000000..012eff15 --- /dev/null +++ b/library/Tiger/Git/WorkingTree.php @@ -0,0 +1,112 @@ +dir = $real; + $this->owner = $owner; + $this->repo = $repo; + $this->defaultBranch = $defaultBranch; + $this->currentBranch = $currentBranch; + $this->transport = $transport; + $this->credentialRef = $credentialRef; + $this->writeEnabled = $writeEnabled; + } + + public function dir(): string { return $this->dir; } + public function owner(): string { return $this->owner; } + public function repo(): string { return $this->repo; } + public function slug(): string { return $this->owner . '/' . $this->repo; } + public function defaultBranch(): string { return $this->defaultBranch; } + public function currentBranch(): string { return $this->currentBranch; } + public function transport(): string { return $this->transport; } + public function credentialRef(): string { return $this->credentialRef; } + public function writeEnabled(): bool { return $this->writeEnabled; } + + /** + * Resolve a module-relative path to an absolute path UNDER the tree root, or throw PATH_ESCAPE. + * The guard for every file a transport reads/writes — `..`, an absolute path, or a symlink that + * points outside the module dir are all refused. + * + * @param string $relative a module-relative path + * @return string the safe absolute path (may not yet exist, but its parent is in-tree) + */ + public function resolve(string $relative): string + { + $relative = str_replace('\\', '/', $relative); + if ($relative === '' || $relative[0] === '/' || preg_match('~(^|/)\.\.(/|$)~', $relative)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::PATH_ESCAPE, 'path escapes the module directory'); + } + $target = $this->dir . '/' . $relative; + // For an existing path, the real path must sit under the root (catches symlink escapes). + $real = realpath($target); + if ($real !== false) { + if ($real !== $this->dir && strpos($real, $this->dir . '/') !== 0) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::PATH_ESCAPE, 'path escapes the module directory'); + } + return $real; + } + // For a not-yet-existing path, its nearest existing ancestor must be in-tree. + $parent = realpath(dirname($target)); + if ($parent === false || ($parent !== $this->dir && strpos($parent, $this->dir . '/') !== 0)) { + throw new Tiger_Git_Exception(Tiger_Git_Exception::PATH_ESCAPE, 'path escapes the module directory'); + } + return $target; + } + + /** A copy on a different current branch (after a switch). */ + public function withBranch(string $branch): self + { + return new self($this->dir, $this->owner, $this->repo, $this->defaultBranch, $branch, $this->transport, $this->credentialRef, $this->writeEnabled); + } +} diff --git a/library/Tiger/Model/GitCredential.php b/library/Tiger/Model/GitCredential.php new file mode 100644 index 00000000..a8bde467 --- /dev/null +++ b/library/Tiger/Model/GitCredential.php @@ -0,0 +1,53 @@ +fetchRow( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ->where('revoked_at IS NULL') + ->order('created_at DESC') + ); + } + + /** + * The public, SAFE view of a credential row — never the ciphertext. What any surface may show. + * + * @param Zend_Db_Table_Row_Abstract $row + * @return array{id:string, type:string, fingerprint:?string, public_material:?string, scopes:?string, rotated_at:?string, revoked_at:?string} + */ + public static function publicView($row): array + { + return [ + 'id' => (string) $row->git_credential_id, + 'type' => (string) $row->type, + 'fingerprint' => $row->fingerprint !== null ? (string) $row->fingerprint : null, + 'public_material' => $row->public_material !== null ? (string) $row->public_material : null, + 'scopes' => $row->scopes !== null ? (string) $row->scopes : null, + 'rotated_at' => $row->rotated_at !== null ? (string) $row->rotated_at : null, + 'revoked_at' => $row->revoked_at !== null ? (string) $row->revoked_at : null, + ]; + } +} diff --git a/library/Tiger/Model/GitOperationLog.php b/library/Tiger/Model/GitOperationLog.php new file mode 100644 index 00000000..30a598bf --- /dev/null +++ b/library/Tiger/Model/GitOperationLog.php @@ -0,0 +1,59 @@ +fetchAll( + $this->select() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ->order('created_at DESC') + ->limit(max(1, (int) $limit)) + ); + } + + /** + * Record one operation. Returns the new log id. Fail-soft is the CALLER's concern — the audit + * write must not be swallowed silently, but it must also never carry a secret (enforced by the + * column set, not by trust). + * + * @param array $fields org_id, module_key, actor_type, actor_id, operation, branch?, ref_before?, ref_after?, result, error_code? + * @return string + */ + public function record(array $fields): string + { + return (string) $this->insert([ + 'org_id' => (string) ($fields['org_id'] ?? ''), + 'module_key' => (string) ($fields['module_key'] ?? ''), + 'actor_type' => (string) ($fields['actor_type'] ?? self::ACTOR_USER), + 'actor_id' => $fields['actor_id'] ?? null, + 'operation' => (string) ($fields['operation'] ?? ''), + 'branch' => $fields['branch'] ?? null, + 'ref_before' => $fields['ref_before'] ?? null, + 'ref_after' => $fields['ref_after'] ?? null, + 'result' => (string) ($fields['result'] ?? self::RESULT_OK), + 'error_code' => $fields['error_code'] ?? null, + ]); + } +} diff --git a/library/Tiger/Model/GitRepoConnection.php b/library/Tiger/Model/GitRepoConnection.php new file mode 100644 index 00000000..bfde2c2f --- /dev/null +++ b/library/Tiger/Model/GitRepoConnection.php @@ -0,0 +1,36 @@ +fetchRow( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ); + } + + /** Every active connection in an org (for the instance-wide Updates panel). */ + public function forOrg($orgId) + { + return $this->fetchAll( + $this->activeSelect()->where('org_id = ?', (string) $orgId)->order('module_key ASC') + ); + } +} diff --git a/library/Tiger/Model/ModuleInstallPin.php b/library/Tiger/Model/ModuleInstallPin.php new file mode 100644 index 00000000..bdf23a0c --- /dev/null +++ b/library/Tiger/Model/ModuleInstallPin.php @@ -0,0 +1,37 @@ +fetchRow( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ); + } + + /** Every active pin in an org (the Updates panel). */ + public function forOrg($orgId) + { + return $this->fetchAll( + $this->activeSelect()->where('org_id = ?', (string) $orgId)->order('module_key ASC') + ); + } +} diff --git a/library/Tiger/Model/ModuleRelease.php b/library/Tiger/Model/ModuleRelease.php new file mode 100644 index 00000000..7795df92 --- /dev/null +++ b/library/Tiger/Model/ModuleRelease.php @@ -0,0 +1,47 @@ +fetchAll( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ->where('status = ?', self::STATUS_APPROVED) + ->order('created_at DESC') + ); + } + + /** A specific release by tag (to check status / prevent tag reuse), or null. */ + public function byTag($orgId, $moduleKey, $tag) + { + return $this->fetchRow( + $this->activeSelect() + ->where('org_id = ?', (string) $orgId) + ->where('module_key = ?', (string) $moduleKey) + ->where('git_tag = ?', (string) $tag) + ); + } +} diff --git a/modules/git/Bootstrap.php b/modules/git/Bootstrap.php new file mode 100644 index 00000000..8a23e299 --- /dev/null +++ b/modules/git/Bootstrap.php @@ -0,0 +1,19 @@ + [ + // --- a module <-> repo connection ------------------------------------------------- + "CREATE TABLE `git_repo_connection` ( + `git_repo_connection_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL, + `module_key` VARCHAR(191) NOT NULL, + `provider` VARCHAR(32) NOT NULL DEFAULT 'github', + `repo_owner` VARCHAR(100) NOT NULL, + `repo_name` VARCHAR(100) NOT NULL, + `default_branch` VARCHAR(191) NOT NULL DEFAULT 'main', + `transport` VARCHAR(16) NOT NULL DEFAULT 'api', + `write_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `credential_ref` VARCHAR(191) NULL, + `last_verified_at` DATETIME NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'connected', + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` CHAR(36) NULL, + `updated_by` CHAR(36) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + `module_key_active` VARCHAR(191) GENERATED ALWAYS AS (IF(`deleted`=0, `module_key`, NULL)) VIRTUAL, + PRIMARY KEY (`git_repo_connection_id`), + UNIQUE KEY `uq_git_conn_org_module_active` (`org_id`, `module_key_active`), + KEY `ix_git_conn_org` (`org_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + // --- a least-privilege credential, encrypted at rest ------------------------------ + "CREATE TABLE `git_credential` ( + `git_credential_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL, + `module_key` VARCHAR(191) NOT NULL, + `type` VARCHAR(16) NOT NULL, + `ciphertext` LONGTEXT NOT NULL, + `public_material` TEXT NULL, + `fingerprint` VARCHAR(191) NULL, + `scopes` VARCHAR(255) NULL, + `rotated_at` DATETIME NULL, + `revoked_at` DATETIME NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` CHAR(36) NULL, + `updated_by` CHAR(36) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`git_credential_id`), + KEY `ix_git_cred_org_module` (`org_id`, `module_key`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + // --- append-only audit log -------------------------------------------------------- + "CREATE TABLE `git_operation_log` ( + `git_operation_log_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL, + `module_key` VARCHAR(191) NOT NULL, + `actor_type` VARCHAR(16) NOT NULL DEFAULT 'user', + `actor_id` CHAR(36) NULL, + `operation` VARCHAR(64) NOT NULL, + `branch` VARCHAR(191) NULL, + `ref_before` VARCHAR(64) NULL, + `ref_after` VARCHAR(64) NULL, + `result` VARCHAR(32) NOT NULL, + `error_code` VARCHAR(64) NULL, + `created_at` DATETIME NOT NULL, + PRIMARY KEY (`git_operation_log_id`), + KEY `ix_git_log_org_module` (`org_id`, `module_key`, `created_at`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + // --- a submitted/approved release ------------------------------------------------- + "CREATE TABLE `module_release` ( + `module_release_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL, + `module_key` VARCHAR(191) NOT NULL, + `version` VARCHAR(64) NOT NULL, + `git_tag` VARCHAR(191) NOT NULL, + `commit_sha` VARCHAR(64) NOT NULL, + `qa_report_ref` VARCHAR(191) NULL, + `submitted_at` DATETIME NULL, + `decided_at` DATETIME NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'submitted', + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` CHAR(36) NULL, + `updated_by` CHAR(36) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`module_release_id`), + KEY `ix_module_release_org_module` (`org_id`, `module_key`, `status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + + // --- a consumer install's version pin/policy ------------------------------------- + "CREATE TABLE `module_install_pin` ( + `module_install_pin_id` CHAR(36) NOT NULL, + `org_id` CHAR(36) NOT NULL, + `module_key` VARCHAR(191) NOT NULL, + `installed_version` VARCHAR(64) NULL, + `pinned_version` VARCHAR(64) NULL, + `channel` VARCHAR(32) NOT NULL DEFAULT 'notify', + `previous_version` VARCHAR(64) NULL, + `status` VARCHAR(32) NOT NULL DEFAULT 'active', + `deleted` TINYINT(1) NOT NULL DEFAULT 0, + `created_by` CHAR(36) NULL, + `updated_by` CHAR(36) NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + `module_key_active` VARCHAR(191) GENERATED ALWAYS AS (IF(`deleted`=0, `module_key`, NULL)) VIRTUAL, + PRIMARY KEY (`module_install_pin_id`), + UNIQUE KEY `uq_module_pin_org_module_active` (`org_id`, `module_key_active`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + ], + 'down' => [ + "DROP TABLE IF EXISTS `module_install_pin`", + "DROP TABLE IF EXISTS `module_release`", + "DROP TABLE IF EXISTS `git_operation_log`", + "DROP TABLE IF EXISTS `git_credential`", + "DROP TABLE IF EXISTS `git_repo_connection`", + ], +]; diff --git a/modules/git/module.json b/modules/git/module.json new file mode 100644 index 00000000..210d987f --- /dev/null +++ b/modules/git/module.json @@ -0,0 +1,5 @@ +{ + "name": "TigerGit", + "type": "app", + "category": ["developer"] +} diff --git a/tests/Integration/Git/GitDataTest.php b/tests/Integration/Git/GitDataTest.php new file mode 100644 index 00000000..5deb7b0e --- /dev/null +++ b/tests/Integration/Git/GitDataTest.php @@ -0,0 +1,194 @@ +db->rollBack(); // close the harness per-test txn; DDL would auto-commit it anyway + if (!$this->tableExists('git_repo_connection')) { + $m = require dirname(__DIR__, 3) . '/modules/git/migrations/20261021000001_create_git_tables.php'; + foreach ($m['up'] as $sql) { $this->db->query($sql); } + } + $this->db->beginTransaction(); // re-open so THIS test body still rolls back + self::$gitSchema = true; + } + + // Seed a crypto key so Tiger_Crypto::isConfigured() is true (the AgentMcpTest idiom). + $cfg = \Zend_Registry::isRegistered('Zend_Config') ? \Zend_Registry::get('Zend_Config') : new \Zend_Config([], true); + if ($cfg->readOnly()) { $cfg = new \Zend_Config($cfg->toArray(), true); } + $cfg->merge(new \Zend_Config(['tiger' => ['crypto' => ['key' => base64_encode(str_repeat("\x22", 32))]]], true)); + \Zend_Registry::set('Zend_Config', $cfg); + } + + #[Test] + public function the_five_tables_build(): void + { + foreach (['git_repo_connection', 'git_credential', 'git_operation_log', 'module_release', 'module_install_pin'] as $t) { + $this->assertTrue($this->tableExists($t), "expected git table `$t`"); + } + } + + // --- credential: write-only -------------------------------------------------------------- + + #[Test] + public function store_returns_no_secret_and_resolve_round_trips(): void + { + $cred = new Tiger_Git_Credential(); + $out = $cred->store('o1', 'acme/mod', Tiger_Model_GitCredential::TYPE_TOKEN, 'tgr_the_secret', ['fingerprint' => 'fp1', 'scopes' => 'contents:read']); + + $this->assertArrayHasKey('ref', $out); + $flat = json_encode($out['public']); + $this->assertStringNotContainsString('tgr_the_secret', $flat, 'the public view must never carry the secret'); + $this->assertSame('fp1', $out['public']['fingerprint']); + + $this->assertSame('tgr_the_secret', $cred->resolve($out['ref']), 'the transport read path recovers the secret'); + } + + #[Test] + public function resolve_of_an_unknown_ref_is_null_not_a_throw(): void + { + $this->assertNull((new Tiger_Git_Credential())->resolve('00000000-0000-0000-0000-000000000000')); + $this->assertNull((new Tiger_Git_Credential())->resolve('')); + } + + #[Test] + public function revoke_stops_the_secret_resolving(): void + { + $cred = new Tiger_Git_Credential(); + $ref = $cred->store('o1', 'acme/mod', Tiger_Model_GitCredential::TYPE_TOKEN, 's3cret')['ref']; + + $this->assertTrue($cred->revoke($ref)); + $this->assertNull($cred->resolve($ref), 'a revoked credential no longer resolves'); + $this->assertNull((new Tiger_Model_GitCredential())->findById($ref), 'the row is soft-deleted'); + } + + #[Test] + public function an_empty_secret_is_refused(): void + { + $this->expectException(Tiger_Git_Exception::class); + (new Tiger_Git_Credential())->store('o1', 'acme/mod', Tiger_Model_GitCredential::TYPE_TOKEN, ''); + } + + #[Test] + public function rotate_stores_the_new_without_revoking_the_old(): void + { + $cred = new Tiger_Git_Credential(); + $old = $cred->store('o1', 'acme/mod', Tiger_Model_GitCredential::TYPE_TOKEN, 'old')['ref']; + $new = $cred->rotate('o1', 'acme/mod', Tiger_Model_GitCredential::TYPE_TOKEN, 'new')['ref']; + + $this->assertNotSame($old, $new); + $this->assertSame('old', $cred->resolve($old), 'the old credential survives until verification passes'); + $this->assertSame('new', $cred->resolve($new)); + } + + #[Test] + public function a_tiger_secret_ref_is_not_ours_to_revoke(): void + { + $this->assertFalse((new Tiger_Git_Credential())->revoke('ssm://tiger/prod/node/ses_key')); + } + + // --- connection: active-only unique + finders -------------------------------------------- + + #[Test] + public function reconnect_after_disconnect_is_not_blocked_by_the_unique(): void + { + $m = new Tiger_Model_GitRepoConnection(); + $row = ['org_id' => 'o1', 'module_key' => 'acme/mod', 'repo_owner' => 'acme', 'repo_name' => 'mod', 'default_branch' => 'main', 'transport' => 'api']; + + $first = $m->insert($row); + $m->softDelete($m->getAdapter()->quoteInto('git_repo_connection_id = ?', $first)); + + // Same (org_id, module_key) again — a plain UNIQUE would throw here; the active-only one frees it. + $second = $m->insert($row); + $this->assertNotSame($first, $second); + + $live = $m->forModule('o1', 'acme/mod'); + $this->assertNotNull($live); + $this->assertSame($second, $live->git_repo_connection_id); + } + + #[Test] + public function repo_connection_finders(): void + { + $m = new Tiger_Model_GitRepoConnection(); + $m->insert(['org_id' => 'o2', 'module_key' => 'a/one', 'repo_owner' => 'a', 'repo_name' => 'one', 'default_branch' => 'main', 'transport' => 'api']); + $m->insert(['org_id' => 'o2', 'module_key' => 'a/two', 'repo_owner' => 'a', 'repo_name' => 'two', 'default_branch' => 'main', 'transport' => 'shell']); + + $this->assertCount(2, $m->forOrg('o2')); + $this->assertSame('shell', $m->forModule('o2', 'a/two')->transport); + $this->assertNull($m->forModule('o2', 'a/missing')); + } + + #[Test] + public function release_finders_by_tag_and_approved(): void + { + $m = new Tiger_Model_ModuleRelease(); + $m->insert(['org_id' => 'o3', 'module_key' => 'a/mod', 'version' => '1.0.0', 'git_tag' => 'v1.0.0', 'commit_sha' => 'abc', 'status' => Tiger_Model_ModuleRelease::STATUS_APPROVED]); + $m->insert(['org_id' => 'o3', 'module_key' => 'a/mod', 'version' => '1.1.0', 'git_tag' => 'v1.1.0', 'commit_sha' => 'def', 'status' => Tiger_Model_ModuleRelease::STATUS_SUBMITTED]); + + $this->assertSame('v1.0.0', $m->byTag('o3', 'a/mod', 'v1.0.0')->git_tag); + $this->assertNull($m->byTag('o3', 'a/mod', 'v9.9.9')); + $this->assertCount(1, $m->approvedForModule('o3', 'a/mod'), 'only the approved release is installable'); + } + + #[Test] + public function operation_log_records_and_reads_back(): void + { + $log = new Tiger_Model_GitOperationLog(); + $log->record(['org_id' => 'o4', 'module_key' => 'a/mod', 'operation' => 'commit', 'branch' => 'main', 'ref_after' => 'deadbeef', 'result' => Tiger_Model_GitOperationLog::RESULT_OK]); + $log->record(['org_id' => 'o4', 'module_key' => 'a/mod', 'operation' => 'push', 'result' => Tiger_Model_GitOperationLog::RESULT_ERROR, 'error_code' => Tiger_Git_Exception::NON_FAST_FORWARD]); + + $rows = $log->recentForModule('o4', 'a/mod'); + $this->assertCount(2, $rows); + $this->assertSame('push', $rows[0]->operation, 'newest first'); + } + + #[Test] + public function install_pin_finder(): void + { + $m = new Tiger_Model_ModuleInstallPin(); + $m->insert(['org_id' => 'o5', 'module_key' => 'a/mod', 'installed_version' => '1.0.0', 'channel' => 'notify']); + $this->assertSame('1.0.0', $m->forModule('o5', 'a/mod')->installed_version); + $this->assertNull($m->forModule('o5', 'a/none')); + } +} diff --git a/tests/Unit/Git/CredentialKeygenTest.php b/tests/Unit/Git/CredentialKeygenTest.php new file mode 100644 index 00000000..fee92a15 --- /dev/null +++ b/tests/Unit/Git/CredentialKeygenTest.php @@ -0,0 +1,53 @@ +generateDeployKey('tiger:my_module'); + + $this->assertStringStartsWith('ssh-ed25519 ', $key['public_openssh']); + $this->assertStringEndsWith(' tiger:my_module', $key['public_openssh']); + $this->assertStringStartsWith('SHA256:', $key['fingerprint']); + $this->assertNotSame('', $key['secret']); + + // The base64 blob between the type and the comment must decode to: string "ssh-ed25519" + a + // 32-byte public key (the OpenSSH wire format). + $b64 = explode(' ', $key['public_openssh'])[1]; + $blob = base64_decode($b64, true); + $this->assertNotFalse($blob); + + $typeLen = unpack('N', substr($blob, 0, 4))[1]; + $this->assertSame(11, $typeLen); + $this->assertSame('ssh-ed25519', substr($blob, 4, 11)); + $keyLen = unpack('N', substr($blob, 15, 4))[1]; + $this->assertSame(32, $keyLen, 'an ed25519 public key is 32 bytes'); + } + + #[Test] + public function every_keypair_is_unique(): void + { + $cred = new Tiger_Git_Credential(); + $a = $cred->generateDeployKey(); + $b = $cred->generateDeployKey(); + $this->assertNotSame($a['public_openssh'], $b['public_openssh']); + $this->assertNotSame($a['secret'], $b['secret']); + $this->assertNotSame($a['fingerprint'], $b['fingerprint']); + } +} diff --git a/tests/Unit/Git/EnvironmentTest.php b/tests/Unit/Git/EnvironmentTest.php new file mode 100644 index 00000000..14f6036d --- /dev/null +++ b/tests/Unit/Git/EnvironmentTest.php @@ -0,0 +1,67 @@ + false); + $env = Tiger_Git_Environment::detect(); + + $this->assertFalse($env['outbound']); + $this->assertFalse($env['usable']); + $this->assertSame('', $env['transport']); + $this->assertNotNull($env['remediation']); + $this->assertStringContainsString('github.com', $env['remediation']); + } + + #[Test] + public function outbound_yields_a_usable_transport(): void + { + Tiger_Git_Environment::setProbe(fn (string $h) => true); + $env = Tiger_Git_Environment::detect(); + + $this->assertTrue($env['outbound']); + $this->assertTrue($env['usable'], 'ApiTransport needs only outbound'); + $this->assertContains($env['transport'], [Tiger_Git::TRANSPORT_SHELL, Tiger_Git::TRANSPORT_API]); + $this->assertNull($env['remediation']); + } + + #[Test] + public function the_probe_refuses_a_non_allowlisted_host(): void + { + Tiger_Git_Environment::setProbe(fn (string $h) => true); // even a yes-probe can't reach off-list + $this->assertFalse(Tiger_Git_Environment::outboundReachable('evil.example.com')); + $this->assertTrue(Tiger_Git_Environment::outboundReachable('github.com')); + } + + #[Test] + public function a_throwing_probe_is_treated_as_unreachable(): void + { + Tiger_Git_Environment::setProbe(function (string $h): bool { throw new \RuntimeException('boom'); }); + $this->assertFalse(Tiger_Git_Environment::outboundReachable('github.com')); + } +} diff --git a/tests/Unit/Git/GitTest.php b/tests/Unit/Git/GitTest.php new file mode 100644 index 00000000..601f6b4d --- /dev/null +++ b/tests/Unit/Git/GitTest.php @@ -0,0 +1,65 @@ +assertTrue(Tiger_Git::isSemver('1.0.0')); + $this->assertTrue(Tiger_Git::isSemver('v2.3.1')); + $this->assertTrue(Tiger_Git::isSemver('1.0.0-beta.1')); + $this->assertTrue(Tiger_Git::isSemver('v10.20.30+build.5')); + } + + #[Test] + public function is_semver_rejects_junk_and_partial_versions(): void + { + $this->assertFalse(Tiger_Git::isSemver('1.0')); + $this->assertFalse(Tiger_Git::isSemver('latest')); + $this->assertFalse(Tiger_Git::isSemver('01.0.0')); // leading zero + $this->assertFalse(Tiger_Git::isSemver('')); + } + + #[Test] + public function normalize_version_strips_only_a_leading_v(): void + { + $this->assertSame('1.2.3', Tiger_Git::normalizeVersion('v1.2.3')); + $this->assertSame('1.2.3', Tiger_Git::normalizeVersion('1.2.3')); + $this->assertSame('1.2.3-v2', Tiger_Git::normalizeVersion('v1.2.3-v2')); // only the leading v + } + + #[Test] + public function allowed_host_is_the_github_set_only(): void + { + $this->assertTrue(Tiger_Git::isAllowedHost('github.com')); + $this->assertTrue(Tiger_Git::isAllowedHost('API.GITHUB.COM')); // case-insensitive + $this->assertTrue(Tiger_Git::isAllowedHost('codeload.github.com')); + $this->assertFalse(Tiger_Git::isAllowedHost('evil.example.com')); + $this->assertFalse(Tiger_Git::isAllowedHost('github.com.evil.com')); + } + + #[Test] + public function repo_slug_validates_or_refuses(): void + { + $this->assertSame(['WebTigers', 'tiger-core'], Tiger_Git::repoSlug('WebTigers', 'tiger-core')); + $this->assertSame(['a_b.c', 'd-e.f'], Tiger_Git::repoSlug('a_b.c', 'd-e.f')); + $this->assertNull(Tiger_Git::repoSlug('owner/evil', 'repo')); // a slash can't ride in + $this->assertNull(Tiger_Git::repoSlug('..', 'repo')); + $this->assertNull(Tiger_Git::repoSlug('owner', '')); + } +} diff --git a/tests/Unit/Git/WorkingTreeTest.php b/tests/Unit/Git/WorkingTreeTest.php new file mode 100644 index 00000000..ce600537 --- /dev/null +++ b/tests/Unit/Git/WorkingTreeTest.php @@ -0,0 +1,107 @@ +dir = sys_get_temp_dir() . '/tiger-git-wt-' . bin2hex(random_bytes(4)); + mkdir($this->dir . '/sub', 0700, true); + file_put_contents($this->dir . '/module.json', '{}'); + file_put_contents($this->dir . '/sub/x.php', 'dir . '/sub/x.php'); + @unlink($this->dir . '/module.json'); + @rmdir($this->dir . '/sub'); + @rmdir($this->dir); + parent::tearDown(); + } + + private function tree(): Tiger_Git_WorkingTree + { + return new Tiger_Git_WorkingTree($this->dir, 'WebTigers', 'tiger-core', 'main', 'main', 'api', 'cred-1', true); + } + + #[Test] + public function it_exposes_its_facts(): void + { + $t = $this->tree(); + $this->assertSame('WebTigers/tiger-core', $t->slug()); + $this->assertSame('api', $t->transport()); + $this->assertSame('cred-1', $t->credentialRef()); + $this->assertTrue($t->writeEnabled()); + $this->assertSame(realpath($this->dir), $t->dir()); + } + + #[Test] + public function resolve_accepts_an_in_tree_path(): void + { + $t = $this->tree(); + $this->assertSame(realpath($this->dir . '/sub/x.php'), $t->resolve('sub/x.php')); + } + + #[Test] + public function resolve_allows_a_not_yet_existing_in_tree_path(): void + { + $t = $this->tree(); + // The tree canonicalizes its root via realpath(); a not-yet-existing path is that root + rel. + $this->assertSame(realpath($this->dir) . '/sub/new.php', $t->resolve('sub/new.php')); + } + + #[Test] + public function resolve_refuses_a_parent_traversal(): void + { + $this->expectException(Tiger_Git_Exception::class); + $this->tree()->resolve('../escape.php'); + } + + #[Test] + public function resolve_refuses_an_absolute_path(): void + { + $this->expectException(Tiger_Git_Exception::class); + $this->tree()->resolve('/etc/passwd'); + } + + #[Test] + public function constructor_refuses_a_missing_directory(): void + { + $this->expectException(Tiger_Git_Exception::class); + new Tiger_Git_WorkingTree('/no/such/dir/' . bin2hex(random_bytes(4)), 'a', 'b', 'main', 'main', 'api'); + } + + #[Test] + public function constructor_refuses_an_invalid_slug(): void + { + $this->expectException(Tiger_Git_Exception::class); + new Tiger_Git_WorkingTree($this->dir, 'owner/evil', 'repo', 'main', 'main', 'api'); + } + + #[Test] + public function with_branch_returns_a_copy_on_the_new_branch(): void + { + $t = $this->tree()->withBranch('feature/x'); + $this->assertSame('feature/x', $t->currentBranch()); + $this->assertSame('main', $t->defaultBranch()); + } +}