diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..11eab9d4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# DLoad + +A PHP CLI (`vendor/bin/dload`, also shipped as a PHAR) that downloads binaries and PHARs from GitHub and GitLab releases into a project, driven by the project's `dload.xml`; it also builds custom RoadRunner binaries through Velox. + +- `src/Module/*` holds the modules (Repository, Downloader, Registry, Installer, Velox…); `src/Bootstrap.php` wires the container. +- `resources/software.json` is the built-in software registry; `dload.xsd` is the schema of `dload.xml`. +- `1.x` is the development and release branch: branch from it and target pull requests at it. +- Tests run on Testo: `vendor/bin/testo --json`. Static analysis: `composer psalm`. Code style: `composer cs:fix`. + +## Docs and skills follow behaviour + +A change a user can observe — a new feature, attribute, option, environment variable, or a different default — lands together with its documentation in the same pull request: + +- `README.md`, then the same change in every translation (`README-ru.md`, `README-es.md`, `README-zh.md`), keeping each file's anchor style. +- `dload.xsd` for anything new in `dload.xml`; `resources/software.schema.json` for anything new in `software.json`. +- `skills/dload-fetch-tool` — the skill shipped to projects that use DLoad; its references describe how entries, tokens and the version registry behave, so they go stale with the code. + +`skills/` is the source of truth for skills. `.agents/skills` and `.claude/skills` are copies installed from `skills.json`; edit `skills/` only. + +## Releases + +release-please cuts releases from `1.x`: it writes `CHANGELOG.md` and `resources/version.json` from the commit history, so both files are generated — leave them to it. + +Commit subjects follow conventional commits, and the type picks the changelog section (`.github/.release-please-config.json`): `feat`, `fix`, `perf`, `docs`, `refactor` are listed; `test`, `chore`, `ci`, `build`, `style` are hidden. A commit carrying several changes lists each as its own conventional line at the top of the body, so every change reaches its section. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9dd4261b..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,50 +0,0 @@ -- Always use guidelines from the `docs/guidelines` folder - -## Guidelines Index - -### 📖 Documentation Guidelines -**Path:** `docs/guidelines/how-to-translate-readme-docs.md` -**Value:** Standardizes documentation translation process and multilingual content management -**Key Areas:** -- Translation workflow using LLMs for documentation -- Multilanguage README pattern using ISO 639-1 codes (`README-{lang_code}.md`) -- Quality standards: preserve technical content, ensure natural language flow -- Review process for translated content -- MAPS framework for complex translations - -### 🖥️ Console Command Development -**Path:** `docs/guidelines/how-to-write-console-command.md` -**Value:** Ensures consistent CLI interface design and proper Symfony console integration -**Key Areas:** -- Command structure: extend `Base` class, use `#[AsCommand]` attribute -- Required methods: `configure()` and `execute()` -- Type system: always use `Path` value object instead of strings for file paths -- Interactive patterns: use `$input->isInteractive()` for detection -- Error handling: proper return codes (`Command::SUCCESS`, `Command::FAILURE`, `Command::INVALID`) -- Best practices: method extraction, confirmation dialogs, file operation patterns -- Available services through DI container (Logger, StyleInterface, etc.) - -### 📝 PHP Code Standards -**Path:** `docs/guidelines/how-to-write-php-code-best-practices.md` -**Value:** Maintains modern PHP code quality and leverages latest language features for better performance and maintainability -**Key Areas:** -- Modern PHP 8.1+ features: constructor promotion, union types, match expressions, throw expressions -- Code structure: PER-2 standards, single responsibility, final classes by default -- Enumerations: use enums for fixed value sets, CamelCase naming, backed enums for primitives -- Immutability: readonly properties, `with` prefix for immutable updates -- Type system: precise PHPDoc annotations, generics, non-empty-string types -- Comparison patterns: strict equality (`===`), null coalescing (`??`), avoid `empty()` -- Dependency injection and IoC container patterns - -### 🧪 Testing Guidelines -**Path:** `docs/guidelines/how-to-write-tests.md` -**Value:** Ensures comprehensive test coverage with modern PHPUnit practices and proper test isolation -**Key Areas:** -- Test structure: mirror source structure, `final` test classes, Arrange-Act-Assert pattern -- Module testing: independent test areas with dedicated `Stub` directories -- Naming: `{ClassUnderTest}Test`, descriptive method names -- Modern PHPUnit: PHP 8.1+ attributes (`#[CoversClass]`, `#[DataProvider]`), data providers with generators -- Isolation: mock dependencies, use test doubles, reset state between tests -- **Critical restrictions**: DO NOT mock enums or final classes - use real instances -- Error testing: expectException before Act phase -- Test traits for shared functionality \ No newline at end of file diff --git a/README-es.md b/README-es.md index 6d128ec3..7ed39977 100644 --- a/README-es.md +++ b/README-es.md @@ -60,6 +60,7 @@ Con DLoad puedes: - [Registro de Software Personalizado](#registro-de-software-personalizado) - [Definir Software](#definir-software) - [Elementos de Software](#elementos-de-software) + - [Servidores Autoalojados](#servidores-autoalojados) - [Casos de Uso](#casos-de-uso) - [Configurar Entorno de Desarrollo](#configurar-entorno-de-desarrollo) - [Configurar Nuevo Proyecto](#configurar-nuevo-proyecto) @@ -588,8 +589,8 @@ El binario de RoadRunner construido incluirá solo los plugins especificados en #### Configuración de Repository -- **type**: Actualmente soporta "github" -- **uri**: Ruta del repositorio (ej., "username/repo") +- **type**: "github" o "gitlab" +- **uri**: Ruta del repositorio (ej., "username/repo") o su URL en un servidor autoalojado, consulta [Servidores Autoalojados](#servidores-autoalojados) - **asset-pattern**: Patrón regex para hacer match con assets de release - **tag-prefix**: Texto antes de la versión en los tags de release, p. ej. `bun-` para `bun-v1.4.2`. Los releases con otros tags se ignoran, así que de un monorepo con tags por componente (release-please) solo se usa el componente seleccionado @@ -605,6 +606,56 @@ El binario de RoadRunner construido incluirá solo los plugins especificados en - **extract-path**: Directorio de extracción opcional - Funciona en cualquier sistema (sin filtrado por SO/arquitectura) +### Servidores Autoalojados + +Un repositorio en GitHub Enterprise Server, en un GitLab autoalojado o en cualquier otra instancia on-premise +se indica con su URL en `uri`, en lugar de una ruta en `github.com` / `gitlab.com`: + +```xml + + + + + + + + +``` + +- El esquema, el host y el puerto de la URL son el servidor; la ruta es el repositorio: `owner/repo` para GitHub, + la ruta completa del proyecto con subgrupos para GitLab. Una `/` final y el sufijo `.git` se ignoran, + así que también sirve una URL de clonado. La URL de una página dentro del repositorio (`/releases`, `/-/tags`) es un error. +- El esquema es obligatorio: `ghe.example.com/my-org/tool` no es una URL, y dload nunca toma un servidor de ella. +- Una URL del host público (`https://github.com/owner/repo`, `https://gitlab.com/group/project`) significa el host + público, igual que una ruta simple. +- La API se encuentra en `{server}/api/v3` para GitHub Enterprise Server y en `{server}/api/v4` para GitLab. + Un GitLab instalado bajo una raíz de URL relativa (`https://example.com/gitlab/...`) no está soportado. + +El token se declara por servidor en una variable de entorno con su nombre: `DLOAD_TOKEN_` seguido del +host en mayúsculas, donde un punto se convierte en `_` y un guion en `__`; un puerto distinto del predeterminado +va después de `___`. Una dirección IPv6 empieza por `_` y enumera sus ocho grupos. Cada servidor tiene su propio +nombre, así que `ghe.example.com` y `ghe-example.com` nunca comparten un token. El esquema no forma parte del nombre. + +| Servidor | Variable del token | +|---------------------------|-------------------------------------------------| +| `github.com` | `DLOAD_TOKEN_GITHUB_COM`, luego `GITHUB_TOKEN` | +| `gitlab.com` | `DLOAD_TOKEN_GITLAB_COM`, luego `GITLAB_TOKEN` | +| `ghe.example.com` | `DLOAD_TOKEN_GHE_EXAMPLE_COM` | +| `git-lab.example.com` | `DLOAD_TOKEN_GIT__LAB_EXAMPLE_COM` | +| `gitlab.example.com:8443` | `DLOAD_TOKEN_GITLAB_EXAMPLE_COM___8443` | +| `http://[::1]:8080` | `DLOAD_TOKEN__0_0_0_0_0_0_0_1___8080` | + +```bash +DLOAD_TOKEN_GHE_EXAMPLE_COM=your_token_here ./vendor/bin/dload get +``` + +La configuración no puede elegir qué variable se lee, así que un `dload.xml` de terceros no puede enviar +tus tokens a ningún sitio. `GITHUB_TOKEN` y `GITLAB_TOKEN` nunca se envían a un servidor autoalojado, y +el token de un servidor solo va a ese esquema, host y puerto exactos, y se descarta cuando el servidor redirige a otro sitio. + +Se permite `http` sin cifrar, p. ej. para una API falsa local en tests (`uri="http://127.0.0.1:8080/owner/repo"`), pero el token +solo se envía por él a `localhost`, `127.0.0.0/8` y `[::1]`. + ## Casos de Uso ### Configurar Entorno de Desarrollo @@ -682,6 +733,10 @@ GITLAB_TOKEN=your_token_here ./vendor/bin/dload get Agrégalo a las variables de entorno CI/CD para descargas automatizadas. +`DLOAD_TOKEN_GITHUB_COM` y `DLOAD_TOKEN_GITLAB_COM` tienen prioridad sobre `GITHUB_TOKEN` y `GITLAB_TOKEN`, +lo que ayuda cuando esos nombres ya los usa otra herramienta. Los servidores autoalojados tienen sus propias variables, +consulta [Servidores Autoalojados](#servidores-autoalojados). + ## Configuración de Gitlab CI Al crear un release en Gitlab, asegúrate de subir tus assets a la página del release a través del diff --git a/README-ru.md b/README-ru.md index 73058e71..b1b93bf7 100644 --- a/README-ru.md +++ b/README-ru.md @@ -61,6 +61,7 @@ DLoad решает распространённую проблему в PHP-пр - [Пользовательский реестр ПО](#пользовательский-реестр-по) - [Определение ПО](#определение-по) - [Элементы ПО](#элементы-по) + - [Собственные серверы](#собственные-серверы) - [Сценарии использования](#сценарии-использования) - [Настройка среды разработки](#настройка-среды-разработки) - [Настройка нового проекта](#настройка-нового-проекта) @@ -590,8 +591,8 @@ DLoad автоматически управляет процессом сбор #### Конфигурация репозитория -- **type**: В настоящее время поддерживает "github" -- **uri**: Путь репозитория (например, "username/repo") +- **type**: "github" или "gitlab" +- **uri**: Путь репозитория (например, "username/repo") или его URL на собственном сервере, см. [Собственные серверы](#собственные-серверы) - **asset-pattern**: Regex-паттерн для соответствия ресурсам релиза - **tag-prefix**: Текст перед версией в тегах релизов, например `bun-` для `bun-v1.4.2`. Релизы с другими тегами игнорируются, поэтому из монорепозитория с тегами по компонентам (release-please) берётся только нужный компонент @@ -607,6 +608,56 @@ DLoad автоматически управляет процессом сбор - **extract-path**: Опциональная папка извлечения - Работает на любой системе (без фильтрации по ОС/архитектуре) +### Собственные серверы + +Репозиторий на GitHub Enterprise Server, собственном GitLab или любом другом локально развёрнутом экземпляре +задаётся в `uri` своим URL вместо пути на `github.com` / `gitlab.com`: + +```xml + + + + + + + + +``` + +- Схема, хост и порт URL — это сервер, путь — репозиторий: `owner/repo` для GitHub, полный путь проекта + с подгруппами для GitLab. Завершающий `/` и суффикс `.git` игнорируются, поэтому подойдёт и URL для клонирования. + URL страницы внутри репозитория (`/releases`, `/-/tags`) — ошибка. +- Схема обязательна: `ghe.example.com/my-org/tool` — не URL, и сервер из такого значения dload никогда не берёт. +- URL публичного хоста (`https://github.com/owner/repo`, `https://gitlab.com/group/project`) означает публичный + хост, как и просто путь. +- API находится по адресу `{server}/api/v3` для GitHub Enterprise Server и `{server}/api/v4` для GitLab. + GitLab, установленный с относительным корнем URL (`https://example.com/gitlab/...`), не поддерживается. + +Токен задаётся для каждого сервера в отдельной переменной окружения, названной по нему: `DLOAD_TOKEN_`, а за ним +хост в верхнем регистре, где точка заменяется на `_`, а дефис — на `__`; порт, отличный от стандартного, идёт +после `___`. IPv6-адрес начинается с `_` и перечисляет все восемь групп. У каждого сервера своё имя, +поэтому `ghe.example.com` и `ghe-example.com` никогда не получат один и тот же токен. Схема в имя не входит. + +| Сервер | Переменная с токеном | +|---------------------------|-------------------------------------------------| +| `github.com` | `DLOAD_TOKEN_GITHUB_COM`, затем `GITHUB_TOKEN` | +| `gitlab.com` | `DLOAD_TOKEN_GITLAB_COM`, затем `GITLAB_TOKEN` | +| `ghe.example.com` | `DLOAD_TOKEN_GHE_EXAMPLE_COM` | +| `git-lab.example.com` | `DLOAD_TOKEN_GIT__LAB_EXAMPLE_COM` | +| `gitlab.example.com:8443` | `DLOAD_TOKEN_GITLAB_EXAMPLE_COM___8443` | +| `http://[::1]:8080` | `DLOAD_TOKEN__0_0_0_0_0_0_0_1___8080` | + +```bash +DLOAD_TOKEN_GHE_EXAMPLE_COM=your_token_here ./vendor/bin/dload get +``` + +Конфигурация не может выбрать, какая переменная будет прочитана, поэтому сторонний `dload.xml` не сможет отправить +ваши токены куда-либо ещё. `GITHUB_TOKEN` и `GITLAB_TOKEN` никогда не отправляются на собственный сервер, а +токен сервера уходит только на точно совпадающие схему, хост и порт и отбрасывается, если сервер перенаправляет на другой адрес. + +Обычный `http` допускается, например, для локального фейкового API в тестах (`uri="http://127.0.0.1:8080/owner/repo"`), но токен +передаётся по нему только на `localhost`, `127.0.0.0/8` и `[::1]`. + ## Сценарии использования ### Настройка среды разработки @@ -684,6 +735,10 @@ GITLAB_TOKEN=your_token_here ./vendor/bin/dload get Добавьте в переменные окружения CI/CD для автоматических загрузок. +`DLOAD_TOKEN_GITHUB_COM` и `DLOAD_TOKEN_GITLAB_COM` имеют приоритет над `GITHUB_TOKEN` и `GITLAB_TOKEN`, +что пригодится, если эти имена уже заняты другим инструментом. У собственных серверов свои переменные, +см. [Собственные серверы](#собственные-серверы). + ## Конфигурация Gitlab CI При создании релиза в Gitlab обязательно загрузите артефакты на страницу релиза через diff --git a/README-zh.md b/README-zh.md index 6390bcea..70085481 100644 --- a/README-zh.md +++ b/README-zh.md @@ -60,6 +60,7 @@ DLoad 解决了 PHP 项目中的一个实际问题:如何在分发 PHP 代码 - [自定义软件注册表](#自定义软件注册表) - [定义软件](#定义软件) - [软件要素](#软件要素) + - [自托管服务器](#自托管服务器) - [使用场景](#使用场景) - [开发环境配置](#开发环境配置) - [新项目创建](#新项目创建) @@ -581,8 +582,8 @@ DLoad 会自动处理构建过程: #### 仓库配置 -- **type**:目前支持 "github" -- **uri**:仓库路径(例如 "username/repo") +- **type**:"github" 或 "gitlab" +- **uri**:仓库路径(例如 "username/repo"),或仓库在自托管服务器上的 URL,参见 [自托管服务器](#自托管服务器) - **asset-pattern**:匹配发布资源的正则表达式模式 - **tag-prefix**:发布标签中版本号之前的文本,例如 `bun-v1.4.2` 中的 `bun-`。标签不以它开头的发布会被忽略,因此对于按组件打标签的 monorepo(release-please),只会使用选定的组件 @@ -598,6 +599,52 @@ DLoad 会自动处理构建过程: - **extract-path**:可选的解压目录 - 在任何系统上都能工作(无操作系统/架构过滤) +### 自托管服务器 + +位于 GitHub Enterprise Server、自托管 GitLab 或任何其他本地部署实例上的仓库,在 `uri` 中用它的 URL 指定, +而不是 `github.com` / `gitlab.com` 上的路径: + +```xml + + + + + + + + +``` + +- URL 的协议、主机和端口即服务器,路径即仓库:GitHub 为 `owner/repo`,GitLab 为包含子组的完整项目路径。 + 末尾的 `/` 和 `.git` 后缀会被忽略,因此克隆 URL 也可以使用。指向仓库内部页面的 URL(`/releases`、`/-/tags`)会报错。 +- 协议是必需的:`ghe.example.com/my-org/tool` 不是 URL,dload 永远不会从中取得服务器。 +- 公共主机的 URL(`https://github.com/owner/repo`、`https://gitlab.com/group/project`)表示公共主机,与普通路径相同。 +- GitHub Enterprise Server 的 API 位于 `{server}/api/v3`,GitLab 的 API 位于 `{server}/api/v4`。 + 不支持安装在相对 URL 根路径下的 GitLab(`https://example.com/gitlab/...`)。 + +每个服务器的令牌通过以其命名的环境变量声明:`DLOAD_TOKEN_` 后接大写的主机,其中点替换为 `_`, +连字符替换为 `__`;非默认端口跟在 `___` 之后。IPv6 地址以 `_` 开头并列出全部八个分组。每个服务器都有 +自己的变量名,因此 `ghe.example.com` 和 `ghe-example.com` 永远不会共用令牌。协议不属于变量名的一部分。 + +| 服务器 | 令牌变量 | +|---------------------------|-------------------------------------------------| +| `github.com` | `DLOAD_TOKEN_GITHUB_COM`,其次是 `GITHUB_TOKEN` | +| `gitlab.com` | `DLOAD_TOKEN_GITLAB_COM`,其次是 `GITLAB_TOKEN` | +| `ghe.example.com` | `DLOAD_TOKEN_GHE_EXAMPLE_COM` | +| `git-lab.example.com` | `DLOAD_TOKEN_GIT__LAB_EXAMPLE_COM` | +| `gitlab.example.com:8443` | `DLOAD_TOKEN_GITLAB_EXAMPLE_COM___8443` | +| `http://[::1]:8080` | `DLOAD_TOKEN__0_0_0_0_0_0_0_1___8080` | + +```bash +DLOAD_TOKEN_GHE_EXAMPLE_COM=your_token_here ./vendor/bin/dload get +``` + +配置无法选择读取哪个变量,因此第三方的 `dload.xml` 无法把你的令牌发送到任何地方。`GITHUB_TOKEN` 和 `GITLAB_TOKEN` +永远不会发送到自托管服务器,而某个服务器的令牌只会发送到与之完全一致的协议、主机和端口,服务器重定向到其他地址时令牌会被丢弃。 + +允许使用普通的 `http`,例如用于测试中的本地模拟 API(`uri="http://127.0.0.1:8080/owner/repo"`),但令牌 +只会通过它发送到 `localhost`、`127.0.0.0/8` 和 `[::1]`。 + ## 使用场景 ### 开发环境配置 @@ -675,6 +722,9 @@ GITLAB_TOKEN=your_token_here ./vendor/bin/dload get 在 CI/CD 环境变量中添加此配置,以便自动下载。 +`DLOAD_TOKEN_GITHUB_COM` 和 `DLOAD_TOKEN_GITLAB_COM` 优先于 `GITHUB_TOKEN` 和 `GITLAB_TOKEN`, +当这些名称已被其他工具占用时很有用。自托管服务器有各自的变量,参见 [自托管服务器](#自托管服务器)。 + ## Gitlab CI 配置 在 Gitlab 中创建发布时,请确保通过包管理器将构建产物上传到发布页面。 diff --git a/README.md b/README.md index e07d025d..79f9e6a3 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ With DLoad, you can: - [Custom Software Registry](#custom-software-registry) - [Defining Software](#defining-software) - [Software Elements](#software-elements) + - [Self-Hosted Servers](#self-hosted-servers) - [Use Cases](#use-cases) - [Development Environment Setup](#development-environment-setup) - [New Project Setup](#new-project-setup) @@ -594,8 +595,8 @@ This ensures consistent Velox versions across different environments and team me #### Repository Configuration -- **type**: Currently supports "github" -- **uri**: Repository path (e.g., "username/repo") +- **type**: "github" or "gitlab" +- **uri**: Repository path (e.g., "username/repo"), or its URL on a self-hosted server, see [Self-Hosted Servers](#self-hosted-servers) - **asset-pattern**: Regex pattern to match release assets - **tag-prefix**: Text before the version in release tags, e.g. `bun-` for `bun-v1.4.2`. Releases with other tags are ignored, so only the selected component of a monorepo with per-component tags (release-please) is used @@ -611,6 +612,56 @@ This ensures consistent Velox versions across different environments and team me - **extract-path**: Optional extraction directory - Works on any system (no OS/architecture filtering) +### Self-Hosted Servers + +A repository on GitHub Enterprise Server, a self-hosted GitLab or any other on-premise instance +is given by its URL in `uri`, instead of a path on `github.com` / `gitlab.com`: + +```xml + + + + + + + + +``` + +- The scheme, host and port of the URL are the server, the path is the repository: `owner/repo` for GitHub, + the full project path with subgroups for GitLab. A trailing `/` and a `.git` suffix are ignored, + so a clone URL works too. A URL of a page inside the repository (`/releases`, `/-/tags`) is an error. +- The scheme is required: `ghe.example.com/my-org/tool` is not a URL, and dload never takes a server from it. +- A URL of the public host (`https://github.com/owner/repo`, `https://gitlab.com/group/project`) means the public + host, as does a plain path. +- The API lives at `{server}/api/v3` for GitHub Enterprise Server and at `{server}/api/v4` for GitLab. + A GitLab installed under a relative URL root (`https://example.com/gitlab/...`) is not supported. + +A token is declared per server in an environment variable named after it: `DLOAD_TOKEN_` followed by +the host in upper case, where a dot becomes `_` and a hyphen `__`; a port other than the default follows +after `___`. An IPv6 address starts with `_` and lists all eight groups. Each server gets a name of its own, +so `ghe.example.com` and `ghe-example.com` never share a token. The scheme is not a part of the name. + +| Server | Token variable | +|---------------------------|-------------------------------------------------| +| `github.com` | `DLOAD_TOKEN_GITHUB_COM`, then `GITHUB_TOKEN` | +| `gitlab.com` | `DLOAD_TOKEN_GITLAB_COM`, then `GITLAB_TOKEN` | +| `ghe.example.com` | `DLOAD_TOKEN_GHE_EXAMPLE_COM` | +| `git-lab.example.com` | `DLOAD_TOKEN_GIT__LAB_EXAMPLE_COM` | +| `gitlab.example.com:8443` | `DLOAD_TOKEN_GITLAB_EXAMPLE_COM___8443` | +| `http://[::1]:8080` | `DLOAD_TOKEN__0_0_0_0_0_0_0_1___8080` | + +```bash +DLOAD_TOKEN_GHE_EXAMPLE_COM=your_token_here ./vendor/bin/dload get +``` + +The configuration cannot choose which variable is read, so a `dload.xml` from a third party cannot send +your tokens anywhere. `GITHUB_TOKEN` and `GITLAB_TOKEN` are never sent to a self-hosted server, and +a server's token goes only to that exact scheme, host and port, and is dropped when the server redirects elsewhere. + +Plain `http` is allowed, e.g. for a local fake API in tests (`uri="http://127.0.0.1:8080/owner/repo"`), but a token +is sent over it only to `localhost`, `127.0.0.0/8` and `[::1]`. + ## Use Cases ### Development Environment Setup @@ -688,6 +739,10 @@ GITLAB_TOKEN=your_token_here ./vendor/bin/dload get Add to CI/CD environment variables for automated downloads. +`DLOAD_TOKEN_GITHUB_COM` and `DLOAD_TOKEN_GITLAB_COM` take precedence over `GITHUB_TOKEN` and `GITLAB_TOKEN`, +which helps when those names are already taken by another tool. Self-hosted servers have their own variables, +see [Self-Hosted Servers](#self-hosted-servers). + > [!NOTE] > In GitHub Actions, `secrets.GITHUB_TOKEN` is scoped to the current repository and shares a limit of > 1,000 requests per hour across all jobs of the repository. With a large job matrix the limit may run out, diff --git a/context.yaml b/context.yaml deleted file mode 100644 index 765edc37..00000000 --- a/context.yaml +++ /dev/null @@ -1,53 +0,0 @@ ---- - -$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json' - -import: - - path: resources/prompts.yaml - -documents: - # Project structure overview - - description: 'Project structure overview' - outputPath: project-structure.md - overwrite: true - sources: - - type: text - content: | - The PSR-4 is used in the project. - - type: tree - sourcePaths: [ 'src' ] - showCharCount: true - showSize: true - - type: file - sourcePaths: [ 'README.md' ] - - # Modules API - - description: 'Modules API allowed to be used in the project' - outputPath: modules-api.md - overwrite: true - sources: - - type: file - sourcePaths: [ 'src/Module', 'src/Service' ] - filePattern: '*.php' - excludePatterns: [ '/Internal/' ] - modifiers: - - name: php-content-filter - options: - method_visibility: - - public - keep_method_bodies: false - - # Guidelines - - description: 'Guidelines and instructions' - outputPath: guidelines.md - overwrite: true - sources: - - type: text - tag: instruction - content: | - There are all the guidelines about how to do some things in the project. - Feel free to load any related guideline to the current context to make the work more efficient. - - type: tree - sourcePaths: 'docs/guidelines' - showCharCount: true - showSize: true diff --git a/dload.xsd b/dload.xsd index 3e86039d..a5b524da 100644 --- a/dload.xsd +++ b/dload.xsd @@ -161,7 +161,7 @@ - Repository URI identifier + Repository path on github.com / gitlab.com, like "owner/repo" or, for GitLab, "group/subgroup/project". A repository on a self-hosted server (GitHub Enterprise Server, self-hosted GitLab) is given by its URL with a scheme: "https://ghe.example.com/owner/repo", "https://gitlab.example.com:8443/group/project"; the API is called at /api/v3 (GitHub) or /api/v4 (GitLab) of that server. A trailing "/" and ".git" are ignored; a URL of a page inside the repository is an error. Without a scheme no server is taken from the value. The API token of a self-hosted server is read from DLOAD_TOKEN_<HOST>: the host upper-cased with "." as "_" and "-" as "__", a non-default port after "___", e.g. DLOAD_TOKEN_GHE_EXAMPLE_COM, DLOAD_TOKEN_GIT__LAB_EXAMPLE_COM___8443. It is never sent over plain http except to loopback hosts, nor to another host a redirect points at. diff --git a/resources/software.schema.json b/resources/software.schema.json index 55d12a97..5c51c8e4 100644 --- a/resources/software.schema.json +++ b/resources/software.schema.json @@ -74,7 +74,7 @@ }, "uri": { "type": "string", - "description": "Repository URI identifier" + "description": "Repository path on the public host, like `owner/repo`, or a URL with a scheme for a self-hosted server, like `https://ghe.example.com/owner/repo`" }, "asset-pattern": { "type": "string", diff --git a/skills/dload-fetch-tool/SKILL.md b/skills/dload-fetch-tool/SKILL.md index 10e70d74..8ed0c559 100644 --- a/skills/dload-fetch-tool/SKILL.md +++ b/skills/dload-fetch-tool/SKILL.md @@ -1,6 +1,6 @@ --- name: dload-fetch-tool -description: Get a CLI tool — native binary or PHAR — from a GitHub release into a project folder with dload (`vendor/bin/dload`). Use when the user wants tool X (rr, temporal, buf, a PHAR…) downloaded into the project or added to `dload.xml`, when a download picks the wrong asset or none, or when setting up dload's release cache (version registry) locally or in CI, including GitHub API rate limits. +description: Get a CLI tool — native binary or PHAR — from a GitHub or GitLab release (public or self-hosted) into a project folder with dload (`vendor/bin/dload`). Use when the user wants tool X (rr, temporal, buf, a PHAR…) downloaded into the project or added to `dload.xml`, when a download picks the wrong asset or none, or when setting up dload's release cache (version registry) locally or in CI, including GitHub API rate limits. --- # Download a GitHub binary or PHAR into a project with dload diff --git a/skills/dload-fetch-tool/references/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index f67c339d..51d8d3cb 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -4,7 +4,7 @@ How to describe a tool dload does not ship, as a `` block inside `/api/v3/repos///releases/tags/`. + +The token comes from `DLOAD_TOKEN_` — the host upper-cased, `.` as `_`, `-` as `__`, a non-default port after `___`, an IPv6 address as `_` plus all eight groups: `DLOAD_TOKEN_GHE_EXAMPLE_COM`, `DLOAD_TOKEN_GIT__LAB_EXAMPLE_COM`, `DLOAD_TOKEN_GITLAB_EXAMPLE_COM___8443`, `DLOAD_TOKEN__0_0_0_0_0_0_0_1___8080` for `[::1]:8080`. The scheme is not part of the name. The token goes only to that exact scheme, host and port and is dropped on a redirect elsewhere. `GITHUB_TOKEN` / `GITLAB_TOKEN` stay with the public hosts. `http://` works (e.g. a local fake API in tests) and carries a token only to `localhost`, `127.0.0.0/8` and `[::1]`. + ## `binary.pattern` Matches the **executable on disk after the asset is downloaded and extracted**: diff --git a/skills/dload-fetch-tool/references/troubleshooting.md b/skills/dload-fetch-tool/references/troubleshooting.md index 5503f256..e1d95b2d 100644 --- a/skills/dload-fetch-tool/references/troubleshooting.md +++ b/skills/dload-fetch-tool/references/troubleshooting.md @@ -17,7 +17,7 @@ curl -s "https://api.github.com/repos///releases?per_page=20" \ - `draft: true` → invisible to dload until published. - Tags carry text before the version (`bun-v1.4.2`, `cli-v2.0.0`) → set `tag-prefix` on the ``; without it the tags do not parse and every release is skipped. See [`registry-entry.md`](registry-entry.md#tag-prefix). - Tag is on GitHub but dload picks an older one → the version registry still holds a fresh check; rerun with `dload get --refresh`. A release inserted below the top of the listing (e.g. a GitLab release with a backdated date) needs `dload cache:clear `. See [`version-registry.md`](version-registry.md). -- API errors (HTTP 401, 403, rate limit) → set a valid `GITHUB_TOKEN` / `GITLAB_TOKEN`. When a check fails but the registry already holds releases of the repository, dload serves the stored ones (a rate limit is reported as "newer ones may be missing"); a never-seen repository fails outright. +- API errors (HTTP 401, 403, rate limit) → set a valid token in the variable the error names: `GITHUB_TOKEN` / `GITLAB_TOKEN` (or `DLOAD_TOKEN_GITHUB_COM` / `DLOAD_TOKEN_GITLAB_COM`, which win) for the public hosts, `DLOAD_TOKEN_` for a repository on a [self-hosted server](registry-entry.md#self-hosted-uri). When a check fails but the registry already holds releases of the repository, dload serves the stored ones (a rate limit is reported as "newer ones may be missing"); a never-seen repository fails outright. ## Stage 2 — asset filtering diff --git a/skills/dload-fetch-tool/references/version-registry.md b/skills/dload-fetch-tool/references/version-registry.md index 7f92f407..0ced385b 100644 --- a/skills/dload-fetch-tool/references/version-registry.md +++ b/skills/dload-fetch-tool/references/version-registry.md @@ -42,7 +42,7 @@ A longer TTL trades freshness for fewer requests: with a floating constraint (`^ Two things together keep CI off the rate limit: **a token** for every request dload does make, and **a carried registry** so it makes few. -- **Token.** Pass `GITHUB_TOKEN` (and `GITLAB_TOKEN` for GitLab sources) into the environment of every step that runs dload — including steps where dload runs indirectly, e.g. from a test suite. Anonymous access is limited to 60 requests per hour per runner IP, which shared runners exhaust quickly. In GitHub Actions `secrets.GITHUB_TOKEN` shares a 1,000 requests/hour limit across all jobs of the repository; a wide matrix that still runs out needs a personal access token. +- **Token.** Pass `GITHUB_TOKEN` (and `GITLAB_TOKEN` for GitLab sources, `DLOAD_TOKEN_` for a [self-hosted server](registry-entry.md#self-hosted-uri)) into the environment of every step that runs dload — including steps where dload runs indirectly, e.g. from a test suite. Anonymous access is limited to 60 requests per hour per runner IP, which shared runners exhaust quickly. In GitHub Actions `secrets.GITHUB_TOKEN` shares a 1,000 requests/hour limit across all jobs of the repository; a wide matrix that still runs out needs a personal access token. - **Registry.** Point `DLOAD_CACHE_DIR` at a directory inside the workspace, as an absolute path, and let the CI cache save and restore it. ### GitHub Actions diff --git a/src/Bootstrap.php b/src/Bootstrap.php index 679d5d6b..a7945c66 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -24,6 +24,7 @@ use Internal\DLoad\Module\Registry\VersionRegistry; use Internal\DLoad\Module\Repository\Internal\GitHub\Factory as GithubRepositoryFactory; use Internal\DLoad\Module\Repository\Internal\GitLab\Factory as GitLabRepositoryFactory; +use Internal\DLoad\Module\Repository\Internal\ServerTokens; use Internal\DLoad\Module\Repository\RepositoryProvider; use Internal\DLoad\Module\Software\SoftwareCollection; use Internal\DLoad\Module\Software\SoftwareCollector; @@ -120,6 +121,7 @@ public function withConfig( $this->container->bind(OperatingSystem::class); $this->container->bind(Libc::class); $this->container->bind(Stability::class); + $this->container->bind(ServerTokens::class, static fn(): ServerTokens => new ServerTokens($environment)); $this->container->bind( RepositoryProvider::class, static fn(Container $container): RepositoryProvider => (new RepositoryProvider()) diff --git a/src/Module/Config/Schema/Embed/Repository.php b/src/Module/Config/Schema/Embed/Repository.php index 4aafa5e9..abaaa6cf 100644 --- a/src/Module/Config/Schema/Embed/Repository.php +++ b/src/Module/Config/Schema/Embed/Repository.php @@ -34,7 +34,10 @@ final class Repository #[XPath('@type')] public string $type = 'github'; - /** @var non-empty-string $uri Repository URI identifier */ + /** + * @var non-empty-string $uri Repository path on the public host, like `owner/repo`, or a URL + * like `https://ghe.example.com/owner/repo` for a self-hosted server. + */ #[XPath('@uri')] public string $uri; diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index a1b36b52..a1276d88 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -25,7 +25,6 @@ use Internal\DLoad\Module\Downloader\Internal\DownloadContext; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Downloader\Task\DownloadTask; -use Internal\DLoad\Module\Registry\RepositoryId; use Internal\DLoad\Module\Registry\VersionRegistry; use Internal\DLoad\Module\Repository\AssetInterface; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; @@ -129,11 +128,13 @@ public function download( // The registry keeps track of which software is served from which repository. The // identity comes from the repository, not the config: the factory may have reduced // a full URL to the path the repository stores its releases under. - $context->repositoryId = new RepositoryId($context->repoConfig->type, $repository->getName()); + $context->repositoryId = $repository->getId(); $this->registry->attach($context->repositoryId, $context->software->getId()); $context->repositoryAttempt = $context->diagnostics->addRepository( type: $context->repoConfig->type, - name: $repository->getName(), + name: $context->repositoryId->server === null + ? $repository->getName() + : $context->repositoryId->server . '/' . $repository->getName(), assetPattern: $context->repoConfig->assetPattern, ); diff --git a/src/Module/HttpClient/Internal/NyholmFactoryImpl.php b/src/Module/HttpClient/Internal/NyholmFactoryImpl.php index ca4ea23f..c9a2b74d 100644 --- a/src/Module/HttpClient/Internal/NyholmFactoryImpl.php +++ b/src/Module/HttpClient/Internal/NyholmFactoryImpl.php @@ -44,11 +44,12 @@ public function request( public function client(): ClientInterface { - return (new Psr18Client())->withOptions([ + return new RedirectingClient((new Psr18Client())->withOptions([ 'timeout' => 30, + 'max_redirects' => 0, 'headers' => [ 'User-Agent' => 'DLoad/' . Info::version(), ], - ]); + ])); } } diff --git a/src/Module/HttpClient/Internal/RedirectingClient.php b/src/Module/HttpClient/Internal/RedirectingClient.php new file mode 100644 index 00000000..d183536b --- /dev/null +++ b/src/Module/HttpClient/Internal/RedirectingClient.php @@ -0,0 +1,130 @@ + $maxRedirects + */ + public function __construct( + private readonly ClientInterface $client, + private readonly int $maxRedirects = 20, + ) {} + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $origin = self::origin($request->getUri()); + + for ($redirects = 0; ; ++$redirects) { + $response = $this->client->sendRequest($request); + $status = $response->getStatusCode(); + $location = $response->getHeaderLine('location'); + + if ($location === '' || !\in_array($status, self::REDIRECTS, true) || $redirects >= $this->maxRedirects) { + return $response; + } + + $uri = self::resolve($request->getUri(), $location); + $request = $request->withUri($uri); + + // Like curl and browsers: 303 always turns into GET, 301 and 302 only turn a POST into GET. + $method = $request->getMethod(); + if ($method !== 'HEAD' && ($status === 303 || ($method === 'POST' && $status !== 307 && $status !== 308))) { + $request = $request->withMethod('GET') + ->withBody(Stream::create('')) + ->withoutHeader('content-type') + ->withoutHeader('content-length') + ->withoutHeader('transfer-encoding'); + } + + if (self::origin($uri) !== $origin) { + foreach (self::CREDENTIALS as $header) { + $request = $request->withoutHeader($header); + } + } + } + } + + /** + * @return string `scheme://host:port` with the default port spelled out. + */ + private static function origin(UriInterface $uri): string + { + $scheme = \strtolower($uri->getScheme()); + $port = (string) ($uri->getPort() ?? ['http' => 80, 'https' => 443][$scheme] ?? ''); + + return $scheme . '://' . \strtolower($uri->getHost()) . ':' . $port; + } + + /** + * Resolves a `Location` value against the URI that answered with it. + */ + private static function resolve(UriInterface $base, string $location): UriInterface + { + $target = new Uri($location); + if ($target->getScheme() !== '') { + return $target; + } + + if ($target->getAuthority() !== '') { + return $target->withScheme($base->getScheme()); + } + + $path = $target->getPath(); + $query = $target->getQuery(); + if ($path === '') { + $path = $base->getPath(); + $query === '' and $query = $base->getQuery(); + } elseif ($path[0] !== '/') { + $basePath = $base->getPath() === '' ? '/' : $base->getPath(); + $path = \substr($basePath, 0, (int) \strrpos($basePath, '/') + 1) . $path; + } + + return $base->withPath(self::removeDotSegments($path)) + ->withQuery($query) + ->withFragment($target->getFragment()); + } + + private static function removeDotSegments(string $path): string + { + $output = []; + foreach (\explode('/', $path) as $segment) { + match ($segment) { + '.' => null, + '..' => \count($output) > 1 ? \array_pop($output) : null, + default => $output[] = $segment, + }; + } + + $last = \substr($path, -3) === '/..' || \substr($path, -2) === '/.' || $path === '.' || $path === '..'; + $last and $output[] = ''; + + return \implode('/', $output); + } +} diff --git a/src/Module/Registry/Internal/FileRegistryStorage.php b/src/Module/Registry/Internal/FileRegistryStorage.php index 652a66cb..098779dc 100644 --- a/src/Module/Registry/Internal/FileRegistryStorage.php +++ b/src/Module/Registry/Internal/FileRegistryStorage.php @@ -320,7 +320,10 @@ private function isEmptyDir(Path $directory): bool private function directoryOf(RepositoryId $id): Path { - return $this->root->join(...\array_map(self::sanitize(...), [$id->type, ...\explode('/', $id->uri)])); + // A self-hosted server gets its own type directory, so the public host keeps the paths it always had + $type = $id->server === null ? $id->type : $id->type . '@' . $id->server; + + return $this->root->join(...\array_map(self::sanitize(...), [$type, ...\explode('/', $id->uri)])); } /** diff --git a/src/Module/Registry/Record/RepositoryRecord.php b/src/Module/Registry/Record/RepositoryRecord.php index 949b85e1..089e91a6 100644 --- a/src/Module/Registry/Record/RepositoryRecord.php +++ b/src/Module/Registry/Record/RepositoryRecord.php @@ -24,7 +24,7 @@ * * @psalm-type RepositoryArray = array{ * version: int, - * repository: array{type: non-empty-string, uri: non-empty-string}, + * repository: array{type: non-empty-string, uri: non-empty-string, server?: non-empty-string}, * checked_at: int|null, * complete: bool, * software: list, @@ -99,6 +99,13 @@ public static function fromArray(array $data, \Closure $loader): self 'Repository record requires a repository type and URI.', ); + /** @var mixed $server */ + $server = \is_array($repository) ? ($repository['server'] ?? null) : null; + $server === null or \is_string($server) && $server !== '' or throw new \InvalidArgumentException( + 'Repository record server must be a non-empty string.', + ); + /** @var non-empty-string|null $server */ + $segments = []; /** @var mixed $segment */ foreach (\is_array($data['segments'] ?? null) ? $data['segments'] : [] as $segment) { @@ -128,7 +135,7 @@ public static function fromArray(array $data, \Closure $loader): self $checkedAt = $data['checked_at'] ?? null; return new self( - id: new RepositoryId($type, $uri), + id: new RepositoryId($type, $uri, $server), checkedAt: \is_int($checkedAt) ? $checkedAt : null, complete: (bool) ($data['complete'] ?? false), software: $software, @@ -369,7 +376,8 @@ public function toArray(): array { return [ 'version' => self::FORMAT_VERSION, - 'repository' => ['type' => $this->id->type, 'uri' => $this->id->uri], + 'repository' => ['type' => $this->id->type, 'uri' => $this->id->uri] + + ($this->id->server === null ? [] : ['server' => $this->id->server]), 'checked_at' => $this->checkedAt, 'complete' => $this->complete, 'software' => $this->software, diff --git a/src/Module/Registry/RepositoryId.php b/src/Module/Registry/RepositoryId.php index f6e410bb..f824a8e3 100644 --- a/src/Module/Registry/RepositoryId.php +++ b/src/Module/Registry/RepositoryId.php @@ -8,14 +8,17 @@ * Identity of a software repository in the version registry. * * The same repository may be referenced by several software packages and by several configs, - * so the registry keys its records by the repository type and path rather than by software name. - * The path is the one the repository reports, not the configured URI: a factory may accept a full - * URL and reduce it. GitHub and GitLab resolve paths case-insensitively, so the identity is - * normalized: lower case, no surrounding slashes. + * so the registry keys its records by the repository type, server and path rather than by + * software name. The path is the one the repository reports, not the configured URI: a factory + * may accept a full URL and reduce it. GitHub and GitLab resolve paths case-insensitively, so the + * identity is normalized: lower case, no surrounding slashes. * * ```php - * $id = new RepositoryId('github', $repository->getName()); + * $id = new RepositoryId('github', 'roadrunner-server/roadrunner'); * echo $id; // github:roadrunner-server/roadrunner + * + * $id = new RepositoryId('github', 'roadrunner-server/roadrunner', 'ghe.example.com'); + * echo $id; // github@ghe.example.com:roadrunner-server/roadrunner * ``` * * @internal @@ -28,23 +31,28 @@ final class RepositoryId implements \Stringable /** @var non-empty-string Repository path within the type, e.g. `owner/repo`. */ public readonly string $uri; + /** @var non-empty-string|null Host and port of a self-hosted instance, null for the public host of the type. */ + public readonly ?string $server; + /** * @param non-empty-string $type * @param non-empty-string $uri + * @param non-empty-string|null $server * @throws \InvalidArgumentException When the URI has nothing but slashes and spaces. */ - public function __construct(string $type, string $uri) + public function __construct(string $type, string $uri, ?string $server = null) { $normalized = \strtolower(\trim($uri, " \t\n\r/")); $normalized === '' and throw new \InvalidArgumentException(\sprintf('Repository URI `%s` is empty.', $uri)); $this->type = \strtolower($type); $this->uri = $normalized; + $this->server = $server === null ? null : \strtolower($server); } public function equals(self $other): bool { - return $this->type === $other->type && $this->uri === $other->uri; + return $this->type === $other->type && $this->uri === $other->uri && $this->server === $other->server; } /** @@ -52,6 +60,6 @@ public function equals(self $other): bool */ public function __toString(): string { - return $this->type . ':' . $this->uri; + return $this->type . ($this->server === null ? '' : '@' . $this->server) . ':' . $this->uri; } } diff --git a/src/Module/Repository/Collection/CompositeRepository.php b/src/Module/Repository/Collection/CompositeRepository.php index 674b0cae..658aa796 100644 --- a/src/Module/Repository/Collection/CompositeRepository.php +++ b/src/Module/Repository/Collection/CompositeRepository.php @@ -4,6 +4,7 @@ namespace Internal\DLoad\Module\Repository\Collection; +use Internal\DLoad\Module\Registry\RepositoryId; use Internal\DLoad\Module\Repository\Repository; /** @@ -46,6 +47,11 @@ public function getName(): string return 'unknown/unknown'; } + public function getId(): RepositoryId + { + return new RepositoryId('composite', $this->getName()); + } + /** * Returns a collection of all releases from all repositories. * diff --git a/src/Module/Repository/Internal/ApiToken.php b/src/Module/Repository/Internal/ApiToken.php new file mode 100644 index 00000000..36973b4b --- /dev/null +++ b/src/Module/Repository/Internal/ApiToken.php @@ -0,0 +1,24 @@ +validator = new ResponseValidator(authenticated: $this->gitHubConfig->token !== null); + $this->validator = new ResponseValidator( + authenticated: $this->token !== null, + tokenVariable: $this->token?->variable ?? $this->server?->tokenVariable(), + ); } /** @@ -57,8 +66,8 @@ public function __construct( public function request(Method|string $method, string|UriInterface $uri, array $headers = []): ResponseInterface { $headers += $this->defaultHeaders; - $this->gitHubConfig->token !== null && self::isTrusted($uri) - and $headers += ['authorization' => 'Bearer ' . $this->gitHubConfig->token]; + $this->token !== null && $this->isTrusted($uri) + and $headers += ['authorization' => 'Bearer ' . $this->token->value]; return $this->sendRequest($this->httpFactory->request($method, $uri, $headers)); } @@ -79,14 +88,17 @@ public function sendRequest(RequestInterface $request): ResponseInterface return $response; } - private static function isTrusted(string|UriInterface $uri): bool + private function isTrusted(string|UriInterface $uri): bool { - $host = $uri instanceof UriInterface ? $uri->getHost() : \parse_url($uri, \PHP_URL_HOST); - if (!\is_string($host) || $host === '') { + if ($this->server !== null) { + return $this->server->isSecure() && $this->server->serves($uri); + } + + [$scheme, $host] = Server::split($uri); + if ($host === '' || $scheme !== 'https') { return false; } - $host = \strtolower($host); foreach (self::TRUSTED_HOSTS as $trusted) { if ($host === $trusted || \str_ends_with($host, '.' . $trusted)) { return true; diff --git a/src/Module/Repository/Internal/GitHub/Api/RepositoryApi.php b/src/Module/Repository/Internal/GitHub/Api/RepositoryApi.php index bc8899f1..c1eb40d1 100644 --- a/src/Module/Repository/Internal/GitHub/Api/RepositoryApi.php +++ b/src/Module/Repository/Internal/GitHub/Api/RepositoryApi.php @@ -25,8 +25,11 @@ */ final class RepositoryApi { - private const URL_REPOSITORY = 'https://api.github.com/repos/%s'; - private const URL_RELEASES = 'https://api.github.com/repos/%s/releases'; + /** API base of the public GitHub; GitHub Enterprise Server serves it at `{server}/api/v3`. */ + public const DEFAULT_BASE_URL = 'https://api.github.com'; + + private const URL_REPOSITORY = '%s/repos/%s'; + private const URL_RELEASES = '%s/repos/%s/releases'; /** * Number of releases to ask for in a single page. GitHub serves 30 by default and allows up to @@ -42,6 +45,7 @@ final class RepositoryApi /** * @param non-empty-string $owner * @param non-empty-string $repo + * @param non-empty-string $baseUrl API base URL without a trailing slash. */ public function __construct( private readonly Client $client, @@ -49,6 +53,7 @@ public function __construct( string $owner, string $repo, private readonly Logger $logger, + private readonly string $baseUrl = self::DEFAULT_BASE_URL, ) { $this->repositoryPath = $owner . '/' . $repo; } @@ -68,7 +73,7 @@ public function request(Method|string $method, string|UriInterface $uri, array $ */ public function getRepository(): RepositoryInfo { - $response = $this->request(Method::Get, \sprintf(self::URL_REPOSITORY, $this->repositoryPath)); + $response = $this->request(Method::Get, \sprintf(self::URL_REPOSITORY, $this->baseUrl, $this->repositoryPath)); /** @var array{ * name: string, @@ -207,7 +212,7 @@ private function decodeReleasesResponse(ResponseInterface $response): array private function releasesRequest(int $page): ResponseInterface { $uri = $this->httpFactory->uri( - \sprintf(self::URL_RELEASES, $this->repositoryPath), + \sprintf(self::URL_RELEASES, $this->baseUrl, $this->repositoryPath), ['page' => $page, 'per_page' => self::RELEASES_PER_PAGE], ); diff --git a/src/Module/Repository/Internal/GitHub/Api/ResponseValidator.php b/src/Module/Repository/Internal/GitHub/Api/ResponseValidator.php index 3ebd3b19..fa4dd8ac 100644 --- a/src/Module/Repository/Internal/GitHub/Api/ResponseValidator.php +++ b/src/Module/Repository/Internal/GitHub/Api/ResponseValidator.php @@ -21,7 +21,7 @@ protected function providerName(): string return 'GitHub'; } - protected function tokenEnvVariable(): string + protected function defaultTokenEnvVariable(): string { return 'GITHUB_TOKEN'; } @@ -36,7 +36,13 @@ protected function repositoryFromUri(string $uri): ?string { // API calls: https://api.github.com/repos/owner/repo/releases // Asset downloads: https://github.com/owner/repo/releases/download/v1.0.0/asset.zip - foreach (['~/repos/([^/?#]+/[^/?#]+)~', '~github\.com/([^/?#]+/[^/?#]+)~'] as $pattern) { + // GitHub Enterprise Server: https://ghe.example.com/owner/repo/releases/download/v1.0.0/asset.zip + $patterns = [ + '~/repos/([^/?#]+/[^/?#]+)~', + '~github\.com/([^/?#]+/[^/?#]+)~', + '~^[a-z]+://[^/]+/([^/?#]+/[^/?#]+)/releases/download/~i', + ]; + foreach ($patterns as $pattern) { if (\preg_match($pattern, $uri, $matches) === 1 && $matches[1] !== '') { /** @var non-empty-string */ return $matches[1]; diff --git a/src/Module/Repository/Internal/GitHub/Factory.php b/src/Module/Repository/Internal/GitHub/Factory.php index 4001393b..0624112d 100644 --- a/src/Module/Repository/Internal/GitHub/Factory.php +++ b/src/Module/Repository/Internal/GitHub/Factory.php @@ -8,8 +8,12 @@ use Internal\DLoad\Module\Config\Schema\GitHub; use Internal\DLoad\Module\HttpClient\Factory as HttpFactory; use Internal\DLoad\Module\Registry\VersionRegistry; +use Internal\DLoad\Module\Repository\Internal\ApiToken; use Internal\DLoad\Module\Repository\Internal\GitHub\Api\Client; use Internal\DLoad\Module\Repository\Internal\GitHub\Api\RepositoryApi; +use Internal\DLoad\Module\Repository\Internal\RepositoryLocation; +use Internal\DLoad\Module\Repository\Internal\Server; +use Internal\DLoad\Module\Repository\Internal\ServerTokens; use Internal\DLoad\Module\Repository\RepositoryFactory; use Internal\DLoad\Service\Logger; @@ -18,27 +22,26 @@ * * This factory creates instances of {@see GitHubRepository} based on the provided configuration. * It checks if the configuration type is 'github' and then initializes the GitHub API client - * and repository API for the specified organization and repository. + * and repository API for the specified organization and repository, on the public GitHub or on + * the GitHub Enterprise Server named by the repository URL. * * @internal * @psalm-internal Internal\DLoad\Module\Repository */ final class Factory implements RepositoryFactory { - private readonly Client $gitHubClient; + public const PUBLIC_SERVER = 'https://github.com'; + + /** @var array Clients by server URL; the public GitHub is under an empty key. */ + private array $clients = []; public function __construct( private readonly HttpFactory $httpFactory, - GitHub $gitHubConfig, + private readonly GitHub $gitHubConfig, private readonly Logger $logger, private readonly VersionRegistry $registry, - ) { - $this->gitHubClient = new Client( - $httpFactory, - $httpFactory->client(), - $gitHubConfig, - ); - } + private readonly ServerTokens $tokens, + ) {} public function supports(RepositoryConfig $config): bool { @@ -47,20 +50,47 @@ public function supports(RepositoryConfig $config): bool public function create(RepositoryConfig $config): GitHubRepository { - $uri = \parse_url($config->uri, PHP_URL_PATH) ?? $config->uri; - [$org, $repo] = \array_slice(\explode('/', $uri), -2); + $location = RepositoryLocation::fromUri($config->uri, self::PUBLIC_SERVER); + \count($location->segments) === 2 or throw $location->invalid( + 'expected `owner/repo` or a URL like `https://ghe.example.com/owner/repo`.', + ); + [$org, $repo] = $location->segments; + $server = $location->server; - $api = $this->createRepositoryApi($org, $repo); + $api = new RepositoryApi( + $this->client($server), + $this->httpFactory, + $org, + $repo, + $this->logger, + $server === null ? RepositoryApi::DEFAULT_BASE_URL : $server->url() . '/api/v3', + ); - return new GitHubRepository($api, $org, $repo, $this->logger, $this->registry, $config->tagPrefix); + return new GitHubRepository($api, $org, $repo, $this->logger, $this->registry, $config->tagPrefix, $server?->authority()); + } + + private function client(?Server $server): Client + { + return $this->clients[$server?->url() ?? ''] ??= new Client( + $this->httpFactory, + $this->httpFactory->client(), + $this->token($server), + $server, + ); } /** - * @param non-empty-string $owner - * @param non-empty-string $repo + * The server's own variable wins over `GITHUB_TOKEN`, which belongs to the public GitHub only. */ - private function createRepositoryApi(string $owner, string $repo): RepositoryApi + private function token(?Server $server): ?ApiToken { - return new RepositoryApi($this->gitHubClient, $this->httpFactory, $owner, $repo, $this->logger); + if ($server !== null) { + return $this->tokens->find($server); + } + + $token = $this->gitHubConfig->token; + + return $this->tokens->find(Server::fromString(self::PUBLIC_SERVER)) + ?? ($token === null || $token === '' ? null : new ApiToken($token, 'GITHUB_TOKEN')); } } diff --git a/src/Module/Repository/Internal/GitHub/GitHubRepository.php b/src/Module/Repository/Internal/GitHub/GitHubRepository.php index d5033e44..bd78ceb5 100644 --- a/src/Module/Repository/Internal/GitHub/GitHubRepository.php +++ b/src/Module/Repository/Internal/GitHub/GitHubRepository.php @@ -37,6 +37,7 @@ final class GitHubRepository implements Repository, Destroyable /** * @param non-empty-string $org * @param non-empty-string $repo + * @param non-empty-string|null $server Host and port of a GitHub Enterprise Server, null for the public GitHub. */ public function __construct( private readonly RepositoryApi $api, @@ -45,6 +46,7 @@ public function __construct( private readonly Logger $logger, private readonly VersionRegistry $registry, private readonly string $tagPrefix = '', + private readonly ?string $server = null, ) { $this->name = $org . '/' . $repo; } @@ -68,7 +70,7 @@ public function getReleases(): ReleasesCollection yield []; $pages = $this->registry->releases( - new RepositoryId(self::TYPE, $this->name), + $this->getId(), new GitHubReleaseSource($this->api), ); $anyPageLoaded = false; @@ -131,6 +133,11 @@ public function getName(): string return $this->name; } + public function getId(): RepositoryId + { + return new RepositoryId(self::TYPE, $this->name, $this->server); + } + public function destroy(): void { // Only what was loaded is released: iterating the collection would request the remaining pages diff --git a/src/Module/Repository/Internal/GitLab/Api/Client.php b/src/Module/Repository/Internal/GitLab/Api/Client.php index 23069575..b9695b79 100644 --- a/src/Module/Repository/Internal/GitLab/Api/Client.php +++ b/src/Module/Repository/Internal/GitLab/Api/Client.php @@ -4,10 +4,11 @@ namespace Internal\DLoad\Module\Repository\Internal\GitLab\Api; -use Internal\DLoad\Module\Config\Schema\GitLab; use Internal\DLoad\Module\HttpClient\Factory as HttpFactory; use Internal\DLoad\Module\HttpClient\Method; use Internal\DLoad\Module\Repository\Exception\RepositoryException; +use Internal\DLoad\Module\Repository\Internal\ApiToken; +use Internal\DLoad\Module\Repository\Internal\Server; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestInterface; @@ -18,13 +19,16 @@ * HTTP client wrapper with GitLab-specific error handling and authentication. * * Converts unsuccessful responses (rate limits, invalid token, missing project, etc.) - * into exceptions with actionable messages. Adds GitLab API token authentication when available. + * into exceptions with actionable messages. Adds the GitLab API token to requests bound for + * the server the client works with. * * @internal * @psalm-internal Internal\DLoad\Module\Repository\Internal\GitLab */ final class Client { + public const PUBLIC_SERVER = 'https://gitlab.com'; + /** * @var array */ @@ -33,16 +37,22 @@ final class Client ]; private readonly ResponseValidator $validator; + private readonly Server $server; + /** + * @param Server|null $server Self-hosted GitLab instance, null for the public GitLab. + */ public function __construct( private readonly HttpFactory $httpFactory, private readonly ClientInterface $client, - private readonly GitLab $gitLabConfig, + private readonly ?ApiToken $token = null, + ?Server $server = null, ) { - // Add authorization header if token is available - $this->gitLabConfig->token !== null and $this->defaultHeaders['authorization'] = 'Bearer ' . $this->gitLabConfig->token; - - $this->validator = new ResponseValidator(authenticated: $this->gitLabConfig->token !== null); + $this->server = $server ?? Server::fromString(self::PUBLIC_SERVER); + $this->validator = new ResponseValidator( + authenticated: $this->token !== null, + tokenVariable: $this->token?->variable ?? $server?->tokenVariable(), + ); } /** @@ -50,16 +60,9 @@ public function __construct( */ public function downloadArtifact(string|UriInterface $uri): ResponseInterface { - $headers = []; - if ($this->gitLabConfig->token !== null) { - $headers = [ - 'PRIVATE-TOKEN' => $this->gitLabConfig->token, - ]; - } - - $request = $this->httpFactory->request(Method::Get, $uri, $headers); + $headers = $this->isTrusted($uri) ? ['PRIVATE-TOKEN' => $this->token->value] : []; - return $this->sendRequest($request); + return $this->sendRequest($this->httpFactory->request(Method::Get, $uri, $headers)); } /** @@ -69,9 +72,10 @@ public function downloadArtifact(string|UriInterface $uri): ResponseInterface */ public function request(Method|string $method, string|UriInterface $uri, array $headers = []): ResponseInterface { - $request = $this->httpFactory->request($method, $uri, $headers + $this->defaultHeaders); + $headers += $this->defaultHeaders; + $this->isTrusted($uri) and $headers += ['authorization' => 'Bearer ' . $this->token->value]; - return $this->sendRequest($request); + return $this->sendRequest($this->httpFactory->request($method, $uri, $headers)); } /** @@ -89,4 +93,12 @@ public function sendRequest(RequestInterface $request): ResponseInterface return $response; } + + /** + * @psalm-assert-if-true !null $this->token + */ + private function isTrusted(string|UriInterface $uri): bool + { + return $this->token !== null && $this->server->isSecure() && $this->server->serves($uri); + } } diff --git a/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php b/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php index d5a42c53..cff3235a 100644 --- a/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php +++ b/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php @@ -24,9 +24,12 @@ */ final class RepositoryApi { - private const URL_REPOSITORY = 'https://gitlab.com/api/v4/projects/%s'; - private const URL_RELEASES = 'https://gitlab.com/api/v4/projects/%s/releases'; - private const URL_RELEASE_ASSET = 'https://gitlab.com/api/v4/projects/%s/releases/%s/downloads/%s'; + /** API base of the public GitLab; a self-hosted instance serves it at `{server}/api/v4`. */ + public const DEFAULT_BASE_URL = 'https://gitlab.com/api/v4'; + + private const URL_REPOSITORY = '%s/projects/%s'; + private const URL_RELEASES = '%s/projects/%s/releases'; + private const URL_RELEASE_ASSET = '%s/projects/%s/releases/%s/downloads/%s'; /** * Number of releases to ask for in a single page. GitLab serves 20 by default and allows up to @@ -41,11 +44,13 @@ final class RepositoryApi /** * @param non-empty-string $projectPath + * @param non-empty-string $baseUrl API base URL without a trailing slash. */ public function __construct( private readonly Client $client, private readonly HttpFactory $httpFactory, string $projectPath, + private readonly string $baseUrl = self::DEFAULT_BASE_URL, ) { $this->repositoryPath = $projectPath; } @@ -58,7 +63,7 @@ public function __construct( */ public function downloadArtifact(string $repositoryPath, string $tag, string $fileName): ResponseInterface { - $url = \sprintf(self::URL_RELEASE_ASSET, \urlencode($repositoryPath), \rawurlencode($tag), $fileName); + $url = \sprintf(self::URL_RELEASE_ASSET, $this->baseUrl, \urlencode($repositoryPath), \rawurlencode($tag), $fileName); return $this->client->downloadArtifact($url); } @@ -77,7 +82,7 @@ public function request(Method|string $method, string|UriInterface $uri, array $ */ public function getRepository(): RepositoryInfo { - $response = $this->request(Method::Get, \sprintf(self::URL_REPOSITORY, \urlencode($this->repositoryPath))); + $response = $this->request(Method::Get, \sprintf(self::URL_REPOSITORY, $this->baseUrl, \urlencode($this->repositoryPath))); /** @var array{ * name: non-empty-string, @@ -217,7 +222,7 @@ private function decodeReleasesResponse(ResponseInterface $response): array private function releasesRequest(int $page): ResponseInterface { $uri = $this->httpFactory->uri( - \sprintf(self::URL_RELEASES, \urlencode($this->repositoryPath)), + \sprintf(self::URL_RELEASES, $this->baseUrl, \urlencode($this->repositoryPath)), ['page' => $page, 'per_page' => self::RELEASES_PER_PAGE], ); diff --git a/src/Module/Repository/Internal/GitLab/Api/ResponseValidator.php b/src/Module/Repository/Internal/GitLab/Api/ResponseValidator.php index 712da5c9..4a3dbf84 100644 --- a/src/Module/Repository/Internal/GitLab/Api/ResponseValidator.php +++ b/src/Module/Repository/Internal/GitLab/Api/ResponseValidator.php @@ -21,7 +21,7 @@ protected function providerName(): string return 'GitLab'; } - protected function tokenEnvVariable(): string + protected function defaultTokenEnvVariable(): string { return 'GITLAB_TOKEN'; } diff --git a/src/Module/Repository/Internal/GitLab/Factory.php b/src/Module/Repository/Internal/GitLab/Factory.php index 83e6106f..52cd9be6 100644 --- a/src/Module/Repository/Internal/GitLab/Factory.php +++ b/src/Module/Repository/Internal/GitLab/Factory.php @@ -8,8 +8,12 @@ use Internal\DLoad\Module\Config\Schema\GitLab; use Internal\DLoad\Module\HttpClient\Factory as HttpFactory; use Internal\DLoad\Module\Registry\VersionRegistry; +use Internal\DLoad\Module\Repository\Internal\ApiToken; use Internal\DLoad\Module\Repository\Internal\GitLab\Api\Client; use Internal\DLoad\Module\Repository\Internal\GitLab\Api\RepositoryApi; +use Internal\DLoad\Module\Repository\Internal\RepositoryLocation; +use Internal\DLoad\Module\Repository\Internal\Server; +use Internal\DLoad\Module\Repository\Internal\ServerTokens; use Internal\DLoad\Module\Repository\RepositoryFactory; use Internal\DLoad\Service\Logger; @@ -18,27 +22,24 @@ * * This factory creates instances of {@see GitLabRepository} based on the provided configuration. * It checks if the configuration type is 'gitlab' and then initializes the GitLab API client - * and repository API for the specified organization and repository. + * and repository API for the specified project, on the public GitLab or on the self-hosted + * instance named by the repository URL. * * @internal * @psalm-internal Internal\DLoad\Module\Repository */ final class Factory implements RepositoryFactory { - private readonly Client $gitLabClient; + /** @var array Clients by server URL; the public GitLab is under an empty key. */ + private array $clients = []; public function __construct( private readonly HttpFactory $httpFactory, - GitLab $gitLabConfig, + private readonly GitLab $gitLabConfig, private readonly Logger $logger, private readonly VersionRegistry $registry, - ) { - $this->gitLabClient = new Client( - $httpFactory, - $httpFactory->client(), - $gitLabConfig, - ); - } + private readonly ServerTokens $tokens, + ) {} public function supports(RepositoryConfig $config): bool { @@ -47,18 +48,45 @@ public function supports(RepositoryConfig $config): bool public function create(RepositoryConfig $config): GitLabRepository { - $path = \parse_url($config->uri, PHP_URL_PATH); - $uri = \is_string($path) && $path !== '' ? $path : $config->uri; - $api = $this->createRepositoryApi($uri); + $location = RepositoryLocation::fromUri($config->uri, Client::PUBLIC_SERVER); + \count($location->segments) >= 2 or throw $location->invalid( + 'expected `group/project` (subgroups allowed) or a URL like `https://gitlab.example.com/group/project`.', + ); + $uri = $location->path(); + $server = $location->server; - return new GitLabRepository($api, $uri, $this->logger, $this->registry, $config->tagPrefix); + $api = new RepositoryApi( + $this->client($server), + $this->httpFactory, + $uri, + $server === null ? RepositoryApi::DEFAULT_BASE_URL : $server->url() . '/api/v4', + ); + + return new GitLabRepository($api, $uri, $this->logger, $this->registry, $config->tagPrefix, $server?->authority()); + } + + private function client(?Server $server): Client + { + return $this->clients[$server?->url() ?? ''] ??= new Client( + $this->httpFactory, + $this->httpFactory->client(), + $this->token($server), + $server, + ); } /** - * @param non-empty-string $projectPath + * The server's own variable wins over `GITLAB_TOKEN`, which belongs to the public GitLab only. */ - private function createRepositoryApi(string $projectPath): RepositoryApi + private function token(?Server $server): ?ApiToken { - return new RepositoryApi($this->gitLabClient, $this->httpFactory, $projectPath); + if ($server !== null) { + return $this->tokens->find($server); + } + + $token = $this->gitLabConfig->token; + + return $this->tokens->find(Server::fromString(Client::PUBLIC_SERVER)) + ?? ($token === null || $token === '' ? null : new ApiToken($token, 'GITLAB_TOKEN')); } } diff --git a/src/Module/Repository/Internal/GitLab/GitLabRepository.php b/src/Module/Repository/Internal/GitLab/GitLabRepository.php index 58fde09b..27b8cd66 100644 --- a/src/Module/Repository/Internal/GitLab/GitLabRepository.php +++ b/src/Module/Repository/Internal/GitLab/GitLabRepository.php @@ -36,6 +36,7 @@ final class GitLabRepository implements Repository, Destroyable /** * @param non-empty-string $projectPath + * @param non-empty-string|null $server Host and port of a self-hosted GitLab, null for the public GitLab. */ public function __construct( private readonly RepositoryApi $api, @@ -43,6 +44,7 @@ public function __construct( private readonly Logger $logger, private readonly VersionRegistry $registry, private readonly string $tagPrefix = '', + private readonly ?string $server = null, ) { $this->name = $projectPath; } @@ -66,7 +68,7 @@ public function getReleases(): ReleasesCollection yield []; $pages = $this->registry->releases( - new RepositoryId(self::TYPE, $this->name), + $this->getId(), new GitLabReleaseSource($this->api), ); $anyPageLoaded = false; @@ -128,6 +130,11 @@ public function getName(): string return $this->name; } + public function getId(): RepositoryId + { + return new RepositoryId(self::TYPE, $this->name, $this->server); + } + public function destroy(): void { // Only what was loaded is released: iterating the collection would request the remaining pages diff --git a/src/Module/Repository/Internal/RepositoryLocation.php b/src/Module/Repository/Internal/RepositoryLocation.php new file mode 100644 index 00000000..12816d99 --- /dev/null +++ b/src/Module/Repository/Internal/RepositoryLocation.php @@ -0,0 +1,111 @@ +server; // https://gitlab.example.com:8443 + * echo $location->path(); // group/sub/project + * ``` + * + * @internal + */ +final class RepositoryLocation +{ + /** + * @param non-empty-string $uri The configured value, for error messages. + * @param Server|null $server Null for the public host. + * @param non-empty-list $segments + */ + private function __construct( + public readonly string $uri, + public readonly ?Server $server, + public readonly array $segments, + ) {} + + /** + * @param non-empty-string $publicServer URL of the public host of the repository type. + * @throws \InvalidArgumentException When the value is neither a repository path nor a repository URL. + */ + public static function fromUri(string $uri, string $publicServer): self + { + $value = \trim($uri); + $value === '' and throw new \InvalidArgumentException('Repository URI is empty.'); + $fail = static fn(string $reason): \InvalidArgumentException => self::error($value, $reason); + $public = Server::fromString($publicServer); + + $server = null; + $path = $value; + if (\preg_match('#^([a-z][a-z0-9+.-]*://[^/?\#]*)(.*)$#is', $value, $match) === 1) { + try { + $server = Server::fromString($match[1]); + } catch (\InvalidArgumentException $e) { + throw $fail(\lcfirst($e->getMessage())); + } + + $path = $match[2]; + } + + \strpbrk($path, '?#') === false or throw $fail('a query or fragment is not allowed.'); + + $path = \trim($path, '/'); + $path === '' and throw $fail('no repository path.'); + $segments = \explode('/', $path); + if ($server === null && \strtolower($segments[0]) === $public->host && \count($segments) > 1) { + \array_shift($segments); + } + + // The public host is always reached over HTTPS on its default port, whatever the URL says + if ($server !== null && $server->host === $public->host && $server->port === null) { + $server = null; + } + + $last = \array_key_last($segments); + \str_ends_with($segments[$last], '.git') and $segments[$last] = \substr($segments[$last], 0, -4); + + foreach ($segments as $segment) { + $segment === '-' and throw $fail('the URL points inside a repository; give the repository URL itself.'); + \in_array($segment, ['', '.', '..'], true) and throw $fail('the repository path is malformed.'); + } + + /** @var non-empty-list $segments */ + return new self($value, $server, $segments); + } + + /** + * @return non-empty-string Segments joined with `/`, no surrounding slashes. + */ + public function path(): string + { + return \implode('/', $this->segments); + } + + /** + * An error about this repository, for checks that depend on the repository type. + */ + public function invalid(string $reason): \InvalidArgumentException + { + return self::error($this->uri, $reason); + } + + private static function error(string $uri, string $reason): \InvalidArgumentException + { + return new \InvalidArgumentException(\sprintf('Repository `%s`: %s', $uri, $reason)); + } +} diff --git a/src/Module/Repository/Internal/ResponseValidator.php b/src/Module/Repository/Internal/ResponseValidator.php index 6b62b96d..98b1f383 100644 --- a/src/Module/Repository/Internal/ResponseValidator.php +++ b/src/Module/Repository/Internal/ResponseValidator.php @@ -30,9 +30,12 @@ abstract class ResponseValidator /** * @param bool $authenticated Whether an API token is configured. + * @param non-empty-string|null $tokenVariable Environment variable the token is read from, + * null for the provider default. */ public function __construct( protected readonly bool $authenticated, + private readonly ?string $tokenVariable = null, ) {} /** @@ -126,10 +129,18 @@ public function transportFailure(RequestInterface $request, \Throwable $e): ApiE */ abstract protected function providerName(): string; + /** + * @return non-empty-string Environment variable that holds the API token of the public host. + */ + abstract protected function defaultTokenEnvVariable(): string; + /** * @return non-empty-string Environment variable that holds the API token. */ - abstract protected function tokenEnvVariable(): string; + protected function tokenEnvVariable(): string + { + return $this->tokenVariable ?? $this->defaultTokenEnvVariable(); + } /** * @return non-empty-string How the provider calls a repository, e.g. `repository` or `project`. diff --git a/src/Module/Repository/Internal/Server.php b/src/Module/Repository/Internal/Server.php new file mode 100644 index 00000000..8738562f --- /dev/null +++ b/src/Module/Repository/Internal/Server.php @@ -0,0 +1,190 @@ +url(); // https://ghe.example.com:8443 + * echo $server->tokenVariable(); // DLOAD_TOKEN_GHE_EXAMPLE_COM___8443 + * ``` + * + * @internal + */ +final class Server implements \Stringable +{ + private const DEFAULT_PORTS = ['http' => 80, 'https' => 443]; + + /** + * Dot-separated labels of letters, digits and inner hyphens. {@see self::tokenVariable()} relies + * on a label never starting or ending with a hyphen. + */ + private const HOSTNAME = '/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/'; + + /** + * @param 'http'|'https' $scheme + * @param non-empty-string $host Lower case; an IPv6 address keeps its brackets. + * @param int<1, 65535>|null $port Null for the default port of the scheme. + */ + private function __construct( + public readonly string $scheme, + public readonly string $host, + public readonly ?int $port, + ) {} + + /** + * @param string $value `[scheme://]host[:port]`; the scheme defaults to `https`. + * @throws \InvalidArgumentException When the value is not a bare server address. + */ + public static function fromString(string $value): self + { + $value = \trim($value); + $url = \str_contains($value, '://') ? $value : 'https://' . $value; + $parts = \parse_url($url); + + $fail = static fn(string $reason): \InvalidArgumentException => new \InvalidArgumentException( + \sprintf('Invalid server `%s`: %s', $value, $reason), + ); + + \is_array($parts) && isset($parts['host']) or throw $fail('no host.'); + $scheme = \strtolower($parts['scheme'] ?? ''); + $scheme === 'http' || $scheme === 'https' or throw $fail('only `http` and `https` are supported.'); + isset($parts['user']) || isset($parts['pass']) and throw $fail('credentials are not allowed.'); + \in_array($parts['path'] ?? '', ['', '/'], true) or throw $fail('a path is not allowed.'); + isset($parts['query']) || isset($parts['fragment']) and throw $fail('a query or fragment is not allowed.'); + + $host = \strtolower($parts['host']); + $valid = \str_starts_with($host, '[') + ? \filter_var(\substr($host, 1, -1), \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) !== false + : \preg_match(self::HOSTNAME, $host) === 1; + $valid or throw $fail('the host is malformed.'); + + $port = $parts['port'] ?? null; + if ($port !== null && $port < 1) { + throw $fail('the port is out of range.'); + } + $port === self::DEFAULT_PORTS[$scheme] and $port = null; + + /** + * `parse_url()` rejects a port above 65535 + * @var non-empty-string $host + * @var int<1, 65535>|null $port + */ + return new self($scheme, $host, $port); + } + + /** + * @return array{string, string, int|null} Lower case scheme and host, and the explicit port. + */ + public static function split(string|UriInterface $uri): array + { + if ($uri instanceof UriInterface) { + return [\strtolower($uri->getScheme()), \strtolower($uri->getHost()), $uri->getPort()]; + } + + $parts = \parse_url($uri); + + return \is_array($parts) + ? [\strtolower($parts['scheme'] ?? ''), \strtolower($parts['host'] ?? ''), $parts['port'] ?? null] + : ['', '', null]; + } + + /** + * @return non-empty-string `host[:port]`. + */ + public function authority(): string + { + return $this->port === null ? $this->host : $this->host . ':' . $this->port; + } + + /** + * @return non-empty-string `scheme://host[:port]`, no trailing slash. + */ + public function url(): string + { + return $this->scheme . '://' . $this->authority(); + } + + /** + * Name of the environment variable that holds the API token for this server. + * + * The name is derived from the address so that only the user's environment, never a config + * file, decides which host receives a token. Distinct servers must never share a name, or a + * config naming a lookalike host would receive the token of another one: + * + * - the host is upper-cased, a dot becomes `_` and a hyphen `__`: `ghe.my-corp.com` gives + * `GHE_MY__CORP_COM`. A label never starts or ends with a hyphen, so a run of underscores is + * either a single dot or the hyphens of one label; + * - an IPv6 address starts with `_` and lists all eight groups in hex: `[::1]` gives + * `_0_0_0_0_0_0_0_1`; + * - a non-default port follows after `___`: `ghe.example.com:8443` gives `GHE_EXAMPLE_COM___8443`. + * + * The scheme is not a part: plain HTTP never carries a token to a remote host anyway. + * + * @return non-empty-string + */ + public function tokenVariable(): string + { + if (\str_starts_with($this->host, '[')) { + /** @var array $groups `fromString()` has validated the address */ + $groups = \unpack('n8', (string) \inet_pton(\substr($this->host, 1, -1))); + $host = '_' . \implode('_', \array_map(\dechex(...), $groups)); + } else { + $host = \strtr($this->host, ['.' => '_', '-' => '__']); + } + + $port = $this->port === null ? '' : '___' . $this->port; + + return \strtoupper('DLOAD_TOKEN_' . $host . $port); + } + + public function isLoopback(): bool + { + return $this->host === 'localhost' + || $this->host === '[::1]' + || \str_ends_with($this->host, '.localhost') + || \preg_match('/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $this->host) === 1; + } + + /** + * Whether a token may travel to this server: plain HTTP would expose it on the wire, unless + * the traffic never leaves the machine. + */ + public function isSecure(): bool + { + return $this->scheme === 'https' || $this->isLoopback(); + } + + /** + * Whether the URI points exactly at this server: same scheme, host and port, no subdomains. + */ + public function serves(string|UriInterface $uri): bool + { + [$scheme, $host, $port] = self::split($uri); + $port ??= self::DEFAULT_PORTS[$scheme] ?? null; + + return $scheme === $this->scheme + && $host === $this->host + && $port === ($this->port ?? self::DEFAULT_PORTS[$this->scheme]); + } + + public function equals(self $other): bool + { + return $this->url() === $other->url(); + } + + public function __toString(): string + { + return $this->url(); + } +} diff --git a/src/Module/Repository/Internal/ServerTokens.php b/src/Module/Repository/Internal/ServerTokens.php new file mode 100644 index 00000000..3fe6f744 --- /dev/null +++ b/src/Module/Repository/Internal/ServerTokens.php @@ -0,0 +1,31 @@ + $environment + */ + public function __construct( + private readonly array $environment, + ) {} + + public function find(Server $server): ?ApiToken + { + $variable = $server->tokenVariable(); + $value = $this->environment[$variable] ?? ''; + + return $value === '' ? null : new ApiToken($value, $variable); + } +} diff --git a/src/Module/Repository/Repository.php b/src/Module/Repository/Repository.php index 2297e906..47c1b5f4 100644 --- a/src/Module/Repository/Repository.php +++ b/src/Module/Repository/Repository.php @@ -4,6 +4,7 @@ namespace Internal\DLoad\Module\Repository; +use Internal\DLoad\Module\Registry\RepositoryId; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; /** @@ -27,6 +28,11 @@ interface Repository */ public function getName(): string; + /** + * Returns the identity of the repository in the version registry. + */ + public function getId(): RepositoryId; + /** * Retrieves all available releases from this repository. * diff --git a/tests/Unit/Module/Downloader/Stub/ThrowingRepositoryStub.php b/tests/Unit/Module/Downloader/Stub/ThrowingRepositoryStub.php index d09d4be9..6f202f9c 100644 --- a/tests/Unit/Module/Downloader/Stub/ThrowingRepositoryStub.php +++ b/tests/Unit/Module/Downloader/Stub/ThrowingRepositoryStub.php @@ -4,6 +4,7 @@ namespace Internal\DLoad\Tests\Unit\Module\Downloader\Stub; +use Internal\DLoad\Module\Registry\RepositoryId; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; use Internal\DLoad\Module\Repository\Repository; @@ -22,6 +23,11 @@ public function getName(): string return $this->name; } + public function getId(): RepositoryId + { + return new RepositoryId('github', $this->name); + } + public function getReleases(): ReleasesCollection { throw $this->failure; diff --git a/tests/Unit/Module/HttpClient/Internal/NyholmFactoryImplTest.php b/tests/Unit/Module/HttpClient/Internal/NyholmFactoryImplTest.php new file mode 100644 index 00000000..77af831b --- /dev/null +++ b/tests/Unit/Module/HttpClient/Internal/NyholmFactoryImplTest.php @@ -0,0 +1,22 @@ +client(), RedirectingClient::class); + } +} diff --git a/tests/Unit/Module/HttpClient/Internal/RedirectingClientTest.php b/tests/Unit/Module/HttpClient/Internal/RedirectingClientTest.php new file mode 100644 index 00000000..b682ff4f --- /dev/null +++ b/tests/Unit/Module/HttpClient/Internal/RedirectingClientTest.php @@ -0,0 +1,145 @@ + 'Bearer secret', + 'private-token' => 'secret', + 'job-token' => 'secret', + 'cookie' => 'session=secret', + ]; + + public static function provideForeignTargets(): \Generator + { + yield 'another host' => ['https://evil.example/a.zip']; + yield 'a subdomain' => ['https://cdn.gitlab.example.com/a.zip']; + yield 'another port' => ['https://gitlab.example.com:8443/a.zip']; + yield 'plain http on the same host' => ['http://gitlab.example.com/a.zip']; + yield 'scheme-relative to another host' => ['//evil.example/a.zip']; + } + + public static function provideSameOriginTargets(): \Generator + { + yield 'absolute path' => ['/files/a.zip', 'https://gitlab.example.com/files/a.zip']; + yield 'relative path' => ['files/a.zip', 'https://gitlab.example.com/api/v4/files/a.zip']; + yield 'dot segments' => ['../../files/./a.zip', 'https://gitlab.example.com/files/a.zip']; + yield 'explicit default port' => ['https://GitLab.example.com:443/files/a.zip', 'https://gitlab.example.com/files/a.zip']; + } + + #[DataProvider('provideForeignTargets')] + #[Test] + public function credentialsDoNotFollowARedirectToAnotherOrigin(string $location): void + { + $server = new RedirectServerStub(['https://gitlab.example.com/api/v4/download' => [302, $location]]); + + $response = (new RedirectingClient($server))->sendRequest( + new Request('GET', 'https://gitlab.example.com/api/v4/download', self::CREDENTIALS + ['accept' => '*/*']), + ); + + Assert::same($response->getStatusCode(), 200); + Assert::count($server->sent, 2); + foreach (\array_keys(self::CREDENTIALS) as $header) { + Assert::same($server->sent[1]->getHeaderLine($header), '', "`{$header}` must not follow the redirect."); + } + Assert::same($server->sent[1]->getHeaderLine('accept'), '*/*'); + } + + #[DataProvider('provideSameOriginTargets')] + #[Test] + public function credentialsFollowARedirectWithinTheOrigin(string $location, string $target): void + { + $server = new RedirectServerStub(['https://gitlab.example.com/api/v4/download' => [302, $location]]); + + (new RedirectingClient($server))->sendRequest( + new Request('GET', 'https://gitlab.example.com/api/v4/download', self::CREDENTIALS), + ); + + Assert::same($server->urls()[1], $target); + Assert::same($server->sent[1]->getHeaderLine('private-token'), 'secret'); + Assert::same($server->sent[1]->getHeaderLine('authorization'), 'Bearer secret'); + } + + #[Test] + public function credentialsDoNotComeBackAfterLeavingTheOrigin(): void + { + $server = new RedirectServerStub([ + 'https://gitlab.example.com/a' => [302, 'https://evil.example/b'], + 'https://evil.example/b' => [302, 'https://gitlab.example.com/c'], + ]); + + (new RedirectingClient($server))->sendRequest(new Request('GET', 'https://gitlab.example.com/a', self::CREDENTIALS)); + + Assert::same($server->urls(), ['https://gitlab.example.com/a', 'https://evil.example/b', 'https://gitlab.example.com/c']); + Assert::same($server->sent[2]->getHeaderLine('private-token'), ''); + } + + #[Test] + public function hostHeaderFollowsTheTarget(): void + { + $server = new RedirectServerStub(['https://gitlab.example.com/a' => [301, 'https://cdn.example/b']]); + + (new RedirectingClient($server))->sendRequest(new Request('GET', 'https://gitlab.example.com/a')); + + Assert::same($server->sent[1]->getHeaderLine('host'), 'cdn.example'); + } + + #[Test] + public function seeOtherTurnsAPostIntoAGet(): void + { + $server = new RedirectServerStub(['https://api.example/build' => [303, '/result']]); + + (new RedirectingClient($server))->sendRequest( + new Request('POST', 'https://api.example/build', ['content-type' => 'application/json'], '{"a":1}'), + ); + + Assert::same($server->sent[1]->getMethod(), 'GET'); + Assert::same((string) $server->sent[1]->getBody(), ''); + Assert::same($server->sent[1]->getHeaderLine('content-type'), ''); + } + + #[Test] + public function temporaryRedirectKeepsThePost(): void + { + $server = new RedirectServerStub(['https://api.example/build' => [307, '/v2/build']]); + + (new RedirectingClient($server))->sendRequest(new Request('POST', 'https://api.example/build', [], '{"a":1}')); + + Assert::same($server->sent[1]->getMethod(), 'POST'); + Assert::same((string) $server->sent[1]->getBody(), '{"a":1}'); + } + + #[Test] + public function redirectLoopStopsAtTheLimit(): void + { + $server = new RedirectServerStub(['https://api.example/loop' => [302, '/loop']]); + + $response = (new RedirectingClient($server, maxRedirects: 3))->sendRequest(new Request('GET', 'https://api.example/loop')); + + Assert::same($response->getStatusCode(), 302); + Assert::count($server->sent, 4); + } + + #[Test] + public function responseWithoutLocationIsReturnedAsIs(): void + { + $server = new RedirectServerStub(['https://api.example/a' => [304, '']]); + + $response = (new RedirectingClient($server))->sendRequest(new Request('GET', 'https://api.example/a')); + + Assert::same($response->getStatusCode(), 304); + Assert::count($server->sent, 1); + } +} diff --git a/tests/Unit/Module/HttpClient/Stub/RedirectServerStub.php b/tests/Unit/Module/HttpClient/Stub/RedirectServerStub.php new file mode 100644 index 00000000..13271897 --- /dev/null +++ b/tests/Unit/Module/HttpClient/Stub/RedirectServerStub.php @@ -0,0 +1,46 @@ + */ + public array $sent = []; + + /** + * @param array $redirects Status and `Location` by request URL. + */ + public function __construct( + private readonly array $redirects = [], + ) {} + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->sent[] = $request; + $redirect = $this->redirects[(string) $request->getUri()] ?? null; + + return $redirect === null + ? new Response(200, [], 'ok') + : new Response($redirect[0], ['location' => $redirect[1]]); + } + + /** + * @return list + */ + public function urls(): array + { + return \array_map(static fn(RequestInterface $r): string => (string) $r->getUri(), $this->sent); + } +} diff --git a/tests/Unit/Module/Registry/FileRegistryStorageTest.php b/tests/Unit/Module/Registry/FileRegistryStorageTest.php index adfb58f2..641064c6 100644 --- a/tests/Unit/Module/Registry/FileRegistryStorageTest.php +++ b/tests/Unit/Module/Registry/FileRegistryStorageTest.php @@ -248,6 +248,22 @@ public function recordOfAnotherRepositoryInTheSameDirectoryIsIgnored(): void Assert::notNull($storage->load(new RepositoryId('github', 'owner/re?po'))); } + #[Test] + public function selfHostedServerIsStoredApartFromThePublicHost(): void + { + $storage = $this->storage(); + $public = new RepositoryId('github', 'owner/repo'); + $selfHosted = new RepositoryId('github', 'owner/repo', 'ghe.example.com:8443'); + + $storage->save(self::record('github', 'owner/repo', ['v1'])); + $storage->save((new RepositoryRecord(id: $selfHosted, checkedAt: 1_000))->withHead([new ReleaseRecord('v2', 'v2')])); + + Assert::true(\is_file($this->directory . '/repositories/github/owner/repo/index.json')); + Assert::true(\is_file($this->directory . '/repositories/github_ghe.example.com_8443/owner/repo/index.json')); + Assert::same($storage->load($public)?->releases()[0]->tag, 'v1'); + Assert::same($storage->load($selfHosted)?->releases()[0]->tag, 'v2'); + } + #[Test] public function windowsDeviceNamesAreEscaped(): void { diff --git a/tests/Unit/Module/Registry/RepositoryRecordTest.php b/tests/Unit/Module/Registry/RepositoryRecordTest.php index 18e4aec9..37910c26 100644 --- a/tests/Unit/Module/Registry/RepositoryRecordTest.php +++ b/tests/Unit/Module/Registry/RepositoryRecordTest.php @@ -11,6 +11,7 @@ use Internal\DLoad\Module\Registry\RepositoryId; use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Expect; use Testo\Test; #[Covers(RepositoryRecord::class)] @@ -388,6 +389,40 @@ public function repositoryIdIsNormalized(): void } } + #[Test] + public function repositoryIdOfASelfHostedServerIsDistinct(): void + { + $id = new RepositoryId('github', 'owner/repo', 'GHE.example.com:8443'); + + Assert::same((string) $id, 'github@ghe.example.com:8443:owner/repo'); + Assert::true($id->equals(new RepositoryId('github', 'owner/repo', 'ghe.example.com:8443'))); + Assert::false($id->equals(new RepositoryId('github', 'owner/repo'))); + Assert::false($id->equals(new RepositoryId('github', 'owner/repo', 'ghe.example.com'))); + } + + #[Test] + public function serverSurvivesTheArrayRoundTripAndIsOmittedForThePublicHost(): void + { + $selfHosted = new RepositoryRecord(new RepositoryId('gitlab', 'group/project', 'gitlab.example.com')); + $public = new RepositoryRecord(new RepositoryId('gitlab', 'group/project')); + + $restored = RepositoryRecord::fromArray($selfHosted->toArray(), static fn(): array => []); + + Assert::same($restored->id->server, 'gitlab.example.com'); + Assert::same($public->toArray()['repository'], ['type' => 'gitlab', 'uri' => 'group/project']); + } + + #[Test] + public function fromArrayRejectsAnInvalidServer(): never + { + Expect::exception(\InvalidArgumentException::class)->withMessage('Repository record server must be a non-empty string.'); + + RepositoryRecord::fromArray( + ['version' => RepositoryRecord::FORMAT_VERSION, 'repository' => ['type' => 'github', 'uri' => 'a/b', 'server' => '']], + static fn(): array => [], + ); + } + private static function id(): RepositoryId { return new RepositoryId('github', 'owner/repo'); diff --git a/tests/Unit/Module/Repository/Internal/GitHub/Api/ClientTest.php b/tests/Unit/Module/Repository/Internal/GitHub/Api/ClientTest.php index cd7b1656..3f7e11b0 100644 --- a/tests/Unit/Module/Repository/Internal/GitHub/Api/ClientTest.php +++ b/tests/Unit/Module/Repository/Internal/GitHub/Api/ClientTest.php @@ -4,18 +4,18 @@ namespace Internal\DLoad\Tests\Unit\Module\Repository\Internal\GitHub\Api; -use Internal\DLoad\Module\Config\Schema\GitHub; use Internal\DLoad\Module\HttpClient\Internal\NyholmFactoryImpl; use Internal\DLoad\Module\Repository\Exception\AccessDeniedException; use Internal\DLoad\Module\Repository\Exception\ApiException; use Internal\DLoad\Module\Repository\Exception\AuthenticationException; use Internal\DLoad\Module\Repository\Exception\RateLimitException; use Internal\DLoad\Module\Repository\Exception\RepositoryNotFoundException; +use Internal\DLoad\Module\Repository\Internal\ApiToken; use Internal\DLoad\Module\Repository\Internal\GitHub\Api\Client; +use Internal\DLoad\Module\Repository\Internal\Server; use Internal\DLoad\Service\Logger; use Internal\DLoad\Tests\Unit\Module\Repository\Internal\GitHub\Stub\ClientExceptionStub; use Internal\DLoad\Tests\Unit\Module\Repository\Internal\GitHub\Stub\ClientStub; -use Internal\DLoad\Tests\Unit\Module\Repository\Internal\GitHub\Stub\GitHubConfigStub; use Internal\DLoad\Tests\Unit\Module\Repository\Internal\GitHub\Stub\HttpFactoryStub; use Internal\DLoad\Tests\Unit\Module\Repository\Stub\ResponseStub; use Psr\Http\Client\ClientInterface; @@ -33,7 +33,7 @@ final class ClientTest { private HttpFactoryStub $httpFactory; private ClientStub $httpClient; - private GitHub $gitHubConfig; + private ?ApiToken $token; private Client $client; public static function provideRequestHeaders(): \Generator @@ -146,7 +146,7 @@ public function requestAddsDefaultHeaders(): void $this->httpFactory = $this->httpFactory->withRequest($method, $uri, $request); $this->httpClient = $this->httpClient->withResponse($request, $response); - $this->client = new Client($this->httpFactory, $this->httpClient, $this->gitHubConfig); + $this->client = new Client($this->httpFactory, $this->httpClient, $this->token); $result = $this->client->request($method, $uri); @@ -156,20 +156,12 @@ public function requestAddsDefaultHeaders(): void #[Test] public function requestWithAuthTokenAddsAuthorizationHeader(): void { - $token = 'github_pat_test_token_123'; - $method = 'GET'; - $uri = \Mockery::mock(UriInterface::class)->shouldIgnoreMissing(); - $request = \Mockery::mock(RequestInterface::class)->shouldIgnoreMissing(); - $response = ResponseStub::ok(); - - $gitHubConfigWithToken = GitHubConfigStub::withToken($token); - $this->httpClient = $this->httpClient->withResponse($request, $response); - - $clientWithToken = new Client($this->httpFactory, $this->httpClient, $gitHubConfigWithToken); + $http = new ClientStub(); + $client = new Client(new NyholmFactoryImpl(new Logger()), $http, new ApiToken('github_pat_test_token_123', 'GITHUB_TOKEN')); - $result = $clientWithToken->request($method, $uri); + $client->request('GET', 'https://api.github.com/repos/owner/repo'); - Assert::equals($result, $response); + Assert::same($http->sent[0]->getHeaderLine('authorization'), 'Bearer github_pat_test_token_123'); } #[Test] @@ -177,7 +169,7 @@ public function tokenIsSentToGitHubHostsOnly(): void { // Asset URLs may come from a registry file on disk, so the token must not follow them anywhere $http = new ClientStub(); - $client = new Client(new NyholmFactoryImpl(new Logger()), $http, GitHubConfigStub::withToken('secret')); + $client = new Client(new NyholmFactoryImpl(new Logger()), $http, new ApiToken('secret', 'GITHUB_TOKEN')); $client->request('GET', 'https://api.github.com/repos/owner/repo/releases'); $client->request('GET', 'https://objects.githubusercontent.com/asset'); @@ -193,6 +185,81 @@ public function tokenIsSentToGitHubHostsOnly(): void Assert::same($http->sent[3]->getHeaderLine('accept'), 'application/vnd.github.v3+json'); } + #[Test] + public function tokenIsNotSentToPublicGitHubOverPlainHttp(): void + { + $http = new ClientStub(); + $client = new Client(new NyholmFactoryImpl(new Logger()), $http, new ApiToken('secret', 'GITHUB_TOKEN')); + + $client->request('GET', 'http://github.com/owner/repo/releases/download/v1/rr.tar.gz'); + + Assert::same($http->sent[0]->getHeaderLine('authorization'), ''); + } + + #[Test] + public function enterpriseTokenIsSentToItsServerOnly(): void + { + $http = new ClientStub(); + $client = new Client( + new NyholmFactoryImpl(new Logger()), + $http, + new ApiToken('secret', 'DLOAD_TOKEN_GHE_EXAMPLE_COM'), + Server::fromString('ghe.example.com'), + ); + + $client->request('GET', 'https://ghe.example.com/api/v3/repos/owner/repo/releases'); + $client->request('GET', 'https://ghe.example.com/owner/repo/releases/download/v1/rr.tar.gz'); + $client->request('GET', 'https://api.github.com/repos/owner/repo/releases'); + $client->request('GET', 'https://media.ghe.example.com/asset'); + $client->request('GET', 'http://ghe.example.com/owner/repo/releases/download/v1/rr.tar.gz'); + + Assert::same($http->sent[0]->getHeaderLine('authorization'), 'Bearer secret'); + Assert::same($http->sent[1]->getHeaderLine('authorization'), 'Bearer secret'); + Assert::same($http->sent[2]->getHeaderLine('authorization'), ''); + Assert::same($http->sent[3]->getHeaderLine('authorization'), ''); + Assert::same($http->sent[4]->getHeaderLine('authorization'), ''); + } + + #[Test] + public function tokenIsNotSentToARemoteServerOverPlainHttp(): void + { + $http = new ClientStub(); + $client = new Client( + new NyholmFactoryImpl(new Logger()), + $http, + new ApiToken('secret', 'DLOAD_TOKEN_GHE_EXAMPLE_COM'), + Server::fromString('http://ghe.example.com'), + ); + + $client->request('GET', 'http://ghe.example.com/api/v3/repos/owner/repo/releases'); + + Assert::same($http->sent[0]->getHeaderLine('authorization'), ''); + } + + #[Test] + public function enterpriseErrorsNameTheVariableOfTheServer(): never + { + $request = \Mockery::mock(RequestInterface::class)->shouldIgnoreMissing(); + $this->httpClient = $this->httpClient->withResponse($request, new ResponseStub(401, [], '{"message":"Bad credentials"}')); + $client = new Client($this->httpFactory, $this->httpClient, null, Server::fromString('ghe.example.com')); + + Expect::exception(AuthenticationException::class)->withMessageContaining('DLOAD_TOKEN_GHE_EXAMPLE_COM'); + + $client->sendRequest($request); + } + + #[Test] + public function errorsNameTheVariableTheTokenCameFrom(): never + { + $request = \Mockery::mock(RequestInterface::class)->shouldIgnoreMissing(); + $this->httpClient = $this->httpClient->withResponse($request, new ResponseStub(401, [], '{"message":"Bad credentials"}')); + $client = new Client($this->httpFactory, $this->httpClient, new ApiToken('bad', 'DLOAD_TOKEN_GITHUB_COM')); + + Expect::exception(AuthenticationException::class)->withMessageContaining('DLOAD_TOKEN_GITHUB_COM'); + + $client->sendRequest($request); + } + #[Test] public function requestWithoutTokenDoesNotAddAuthorizationHeader(): void { @@ -218,7 +285,7 @@ public function detectsRateLimitResponseAndThrowsException(): void $this->httpFactory = $this->httpFactory->withRequest($method, $uri, $request); $this->httpClient = $this->httpClient->withResponse($request, $rateLimitResponse); - $this->client = new Client($this->httpFactory, $this->httpClient, $this->gitHubConfig); + $this->client = new Client($this->httpFactory, $this->httpClient, $this->token); Expect::exception(RateLimitException::class)->withMessageContaining('rate limit exceeded'); @@ -230,7 +297,7 @@ public function rateLimitMessageSuggestsTokenWhenThereIsNoToken(): void { $request = \Mockery::mock(RequestInterface::class)->shouldIgnoreMissing(); $this->httpClient = $this->httpClient->withResponse($request, ResponseStub::githubRateLimit()); - $this->client = new Client($this->httpFactory, $this->httpClient, $this->gitHubConfig); + $this->client = new Client($this->httpFactory, $this->httpClient, $this->token); try { $this->client->sendRequest($request); @@ -248,7 +315,7 @@ public function authenticationMessageMentionsConfiguredToken(): void $response = new ResponseStub(401, [], \json_encode(['message' => 'Bad credentials'])); $this->httpClient = $this->httpClient->withResponse($request, $response); - $client = new Client($this->httpFactory, $this->httpClient, GitHubConfigStub::withToken('invalid-token')); + $client = new Client($this->httpFactory, $this->httpClient, new ApiToken('invalid-token', 'GITHUB_TOKEN')); try { $client->sendRequest($request); @@ -266,7 +333,7 @@ public function sendRequestDelegatesToHttpClient(): void $response = ResponseStub::ok(); $this->httpClient = $this->httpClient->withResponse($request, $response); - $this->client = new Client($this->httpFactory, $this->httpClient, $this->gitHubConfig); + $this->client = new Client($this->httpFactory, $this->httpClient, $this->token); $result = $this->client->sendRequest($request); @@ -280,7 +347,7 @@ public function sendRequestWrapsClientExceptionsIntoApiException(): void $clientException = new ClientExceptionStub('connection reset'); $this->httpClient = $this->httpClient->withException($request, $clientException); - $this->client = new Client($this->httpFactory, $this->httpClient, $this->gitHubConfig); + $this->client = new Client($this->httpFactory, $this->httpClient, $this->token); try { $this->client->sendRequest($request); @@ -302,7 +369,7 @@ public function requestMergesHeadersCorrectly(array $additionalHeaders): void $this->httpFactory = $this->httpFactory->withRequest($method, $uri, $request); $this->httpClient = $this->httpClient->withResponse($request, $response); - $this->client = new Client($this->httpFactory, $this->httpClient, $this->gitHubConfig); + $this->client = new Client($this->httpFactory, $this->httpClient, $this->token); $result = $this->client->request($method, $uri, $additionalHeaders); @@ -325,7 +392,7 @@ public function unsuccessfulResponsesAreConvertedIntoExceptions( $response = new ResponseStub($statusCode, $headers, $responseBody); $this->httpClient = $this->httpClient->withResponse($request, $response); - $this->client = new Client($this->httpFactory, $this->httpClient, $this->gitHubConfig); + $this->client = new Client($this->httpFactory, $this->httpClient, $this->token); $expectedException === null or Expect::exception($expectedException); @@ -343,7 +410,7 @@ protected function prepare(): void clientFactory: static fn() => \Mockery::mock(ClientInterface::class)->shouldIgnoreMissing(), ); $this->httpClient = new ClientStub(); - $this->gitHubConfig = GitHubConfigStub::withoutToken(); - $this->client = new Client($this->httpFactory, $this->httpClient, $this->gitHubConfig); + $this->token = null; + $this->client = new Client($this->httpFactory, $this->httpClient, $this->token); } } diff --git a/tests/Unit/Module/Repository/Internal/GitHub/FactoryTest.php b/tests/Unit/Module/Repository/Internal/GitHub/FactoryTest.php new file mode 100644 index 00000000..dba26ba0 --- /dev/null +++ b/tests/Unit/Module/Repository/Internal/GitHub/FactoryTest.php @@ -0,0 +1,178 @@ + ['org/repo', [], null, '']; + yield 'GITHUB_TOKEN on the public host' => ['org/repo', [], 'public', 'Bearer public']; + yield 'server variable wins over GITHUB_TOKEN' => [ + 'org/repo', + ['DLOAD_TOKEN_GITHUB_COM' => 'own'], + 'public', + 'Bearer own', + ]; + yield 'empty server variable is ignored' => ['org/repo', ['DLOAD_TOKEN_GITHUB_COM' => ''], 'public', 'Bearer public']; + yield 'GITHUB_TOKEN never leaves the public host' => ['https://ghe.example.com/org/repo', [], 'public', '']; + yield 'custom server variable' => [ + 'https://ghe.example.com/org/repo', + ['DLOAD_TOKEN_GHE_EXAMPLE_COM' => 'ghe'], + 'public', + 'Bearer ghe', + ]; + yield 'variable of another server' => [ + 'https://ghe.example.com/org/repo', + ['DLOAD_TOKEN_GITHUB_COM' => 'own', 'DLOAD_TOKEN_OTHER_EXAMPLE_COM' => 'other'], + null, + '', + ]; + yield 'no token over plain http' => [ + 'http://ghe.example.com/org/repo', + ['DLOAD_TOKEN_GHE_EXAMPLE_COM' => 'ghe'], + null, + '', + ]; + yield 'plain http on loopback' => [ + 'http://127.0.0.1:8080/org/repo', + ['DLOAD_TOKEN_127_0_0_1___8080' => 'local'], + null, + 'Bearer local', + ]; + } + + public static function provideServers(): \Generator + { + $public = 'https://api.github.com/repos/org/repo/releases'; + yield 'path' => ['org/repo', $public, 'github:org/repo']; + yield 'public url' => ['https://github.com/org/repo', $public, 'github:org/repo']; + yield 'public host without a scheme' => ['github.com/org/repo', $public, 'github:org/repo']; + yield 'public clone url over http' => ['http://GitHub.com/org/repo.git/', $public, 'github:org/repo']; + yield 'enterprise' => [ + 'https://ghe.example.com/org/repo', + 'https://ghe.example.com/api/v3/repos/org/repo/releases', + 'github@ghe.example.com:org/repo', + ]; + yield 'enterprise clone url with a port' => [ + 'https://ghe.example.com:8443/org/repo.git', + 'https://ghe.example.com:8443/api/v3/repos/org/repo/releases', + 'github@ghe.example.com:8443:org/repo', + ]; + yield 'local fake' => [ + 'http://127.0.0.1:8080/org/repo', + 'http://127.0.0.1:8080/api/v3/repos/org/repo/releases', + 'github@127.0.0.1:8080:org/repo', + ]; + } + + public static function provideInvalidUris(): \Generator + { + yield 'single segment' => ['repo']; + yield 'page of a repository' => ['https://github.com/org/repo/releases']; + yield 'host without a scheme' => ['ghe.example.com/org/repo']; + yield 'server without a path' => ['https://ghe.example.com']; + yield 'unsupported scheme' => ['ftp://ghe.example.com/org/repo']; + yield 'credentials' => ['https://user:pass@ghe.example.com/org/repo']; + yield 'malformed host' => ['https://ghe_example.com/org/repo']; + yield 'query' => ['https://ghe.example.com/org/repo?tab=readme']; + yield 'empty segment' => ['org//repo']; + yield 'dot segment' => ['https://ghe.example.com/org/..']; + } + + /** + * @param array $environment + */ + #[DataProvider('provideTokenSources')] + #[Test] + public function sendsTheTokenDeclaredForTheServer( + string $uri, + array $environment, + ?string $gitHubToken, + string $authorization, + ): void { + $factory = $this->factory($environment, $gitHubToken); + + $this->loadReleases($factory, $uri); + + Assert::same($this->lastRequest()->getHeaderLine('authorization'), $authorization); + } + + #[DataProvider('provideServers')] + #[Test] + public function talksToTheApiOfTheServer(string $uri, string $releasesUrl, string $id): void + { + $factory = $this->factory(); + + $repository = $this->loadReleases($factory, $uri); + + $uri = $this->lastRequest()->getUri(); + Assert::same($uri->getScheme() . '://' . $uri->getAuthority() . $uri->getPath(), $releasesUrl); + Assert::same((string) $repository->getId(), $id); + Assert::same($repository->getName(), 'org/repo'); + } + + #[DataProvider('provideInvalidUris')] + #[Test] + public function rejectsAnUriThatIsNotARepository(string $uri): never + { + Expect::exception(\InvalidArgumentException::class)->withMessageContaining("Repository `{$uri}`:"); + + $this->factory()->create(self::config($uri)); + } + + private static function config(string $uri): RepositoryConfig + { + $config = new RepositoryConfig(); + $config->uri = $uri; + + return $config; + } + + /** + * @param array $environment + */ + private function factory(array $environment = [], ?string $gitHubToken = null): Factory + { + $this->http = new RecordingHttpFactory(); + $gitHubConfig = new GitHubConfig(); + $gitHubConfig->token = $gitHubToken; + + return new Factory($this->http, $gitHubConfig, new Logger(), new PassThroughRegistry(), new ServerTokens($environment)); + } + + private function loadReleases(Factory $factory, string $uri): Repository + { + $repository = $factory->create(self::config($uri)); + \iterator_to_array($repository->getReleases()); + + return $repository; + } + + private function lastRequest(): RequestInterface + { + Assert::notBlank($this->http->sent); + + return $this->http->sent[\array_key_last($this->http->sent)]; + } +} diff --git a/tests/Unit/Module/Repository/Internal/GitHub/GitHubRepositoryTest.php b/tests/Unit/Module/Repository/Internal/GitHub/GitHubRepositoryTest.php index e423e12a..e678d35e 100644 --- a/tests/Unit/Module/Repository/Internal/GitHub/GitHubRepositoryTest.php +++ b/tests/Unit/Module/Repository/Internal/GitHub/GitHubRepositoryTest.php @@ -4,7 +4,6 @@ namespace Internal\DLoad\Tests\Unit\Module\Repository\Internal\GitHub; -use Internal\DLoad\Module\Config\Schema\GitHub as GitHubConfig; use Internal\DLoad\Module\HttpClient\Internal\NyholmFactoryImpl; use Internal\DLoad\Module\Registry\Internal\PassThroughRegistry; use Internal\DLoad\Module\Registry\Internal\StoredVersionRegistry; @@ -299,7 +298,7 @@ private static function api(PagedClientStub $client): RepositoryApi $httpFactory = new NyholmFactoryImpl($logger); return new RepositoryApi( - new Client($httpFactory, $client, new GitHubConfig()), + new Client($httpFactory, $client), $httpFactory, 'owner', 'repo', diff --git a/tests/Unit/Module/Repository/Internal/GitHub/Stub/GitHubConfigStub.php b/tests/Unit/Module/Repository/Internal/GitHub/Stub/GitHubConfigStub.php deleted file mode 100644 index 68e2b775..00000000 --- a/tests/Unit/Module/Repository/Internal/GitHub/Stub/GitHubConfigStub.php +++ /dev/null @@ -1,29 +0,0 @@ -token = $token; - return $config; - } - - public static function withoutToken(): GitHub - { - $config = new GitHub(); - $config->token = null; - return $config; - } -} diff --git a/tests/Unit/Module/Repository/Internal/GitLab/Api/ClientTest.php b/tests/Unit/Module/Repository/Internal/GitLab/Api/ClientTest.php new file mode 100644 index 00000000..d5685d66 --- /dev/null +++ b/tests/Unit/Module/Repository/Internal/GitLab/Api/ClientTest.php @@ -0,0 +1,100 @@ +request('GET', 'https://gitlab.com/api/v4/projects/group%2Fproject/releases'); + $client->downloadArtifact('https://gitlab.com/api/v4/projects/group%2Fproject/releases/v1/downloads/a.zip'); + $client->request('GET', 'https://evil.example/api/v4/projects/group%2Fproject/releases'); + $client->downloadArtifact('https://evil.example/gitlab.com/a.zip'); + $client->request('GET', 'http://gitlab.com/api/v4/projects/group%2Fproject/releases'); + + Assert::same($http->sent[0]->getHeaderLine('authorization'), 'Bearer secret'); + Assert::same($http->sent[1]->getHeaderLine('private-token'), 'secret'); + Assert::same($http->sent[2]->getHeaderLine('authorization'), ''); + Assert::same($http->sent[3]->getHeaderLine('private-token'), ''); + Assert::same($http->sent[4]->getHeaderLine('authorization'), ''); + } + + #[Test] + public function selfHostedTokenIsSentToItsServerOnly(): void + { + $http = new RecordingHttpFactory(); + $client = new Client( + $http, + $http, + new ApiToken('secret', 'DLOAD_TOKEN_GITLAB_EXAMPLE_COM___8443'), + Server::fromString('gitlab.example.com:8443'), + ); + + $client->request('GET', 'https://gitlab.example.com:8443/api/v4/projects/group%2Fproject/releases'); + $client->downloadArtifact('https://gitlab.example.com:8443/api/v4/projects/group%2Fproject/releases/v1/downloads/a.zip'); + $client->request('GET', 'https://gitlab.com/api/v4/projects/group%2Fproject/releases'); + $client->downloadArtifact('https://gitlab.example.com/api/v4/projects/group%2Fproject/releases/v1/downloads/a.zip'); + + Assert::same($http->sent[0]->getHeaderLine('authorization'), 'Bearer secret'); + Assert::same($http->sent[1]->getHeaderLine('private-token'), 'secret'); + Assert::same($http->sent[2]->getHeaderLine('authorization'), ''); + Assert::same($http->sent[3]->getHeaderLine('private-token'), ''); + } + + #[Test] + public function tokenIsSentOverPlainHttpOnLoopback(): void + { + $http = new RecordingHttpFactory(); + $local = new Client($http, $http, new ApiToken('local', 'DLOAD_TOKEN_127_0_0_1___8080'), Server::fromString('http://127.0.0.1:8080')); + $remote = new Client($http, $http, new ApiToken('remote', 'DLOAD_TOKEN_GITLAB_EXAMPLE_COM'), Server::fromString('http://gitlab.example.com')); + + $local->request('GET', 'http://127.0.0.1:8080/api/v4/projects/group%2Fproject/releases'); + $remote->request('GET', 'http://gitlab.example.com/api/v4/projects/group%2Fproject/releases'); + + Assert::same($http->sent[0]->getHeaderLine('authorization'), 'Bearer local'); + Assert::same($http->sent[1]->getHeaderLine('authorization'), ''); + } + + #[Test] + public function tokenDoesNotFollowAReleaseLinkToAnotherHost(): void + { + $download = 'https://gitlab.example.com/api/v4/projects/group%2Fproject/releases/v1/downloads/a.zip'; + $releases = 'https://gitlab.example.com/api/v4/projects/group%2Fproject/releases'; + $server = new RedirectServerStub([ + $download => [302, 'https://maintainer.example/a.zip'], + $releases => [302, '/api/v4/projects/7/releases'], + ]); + $client = new Client( + new RecordingHttpFactory(), + new RedirectingClient($server), + new ApiToken('secret', 'DLOAD_TOKEN_GITLAB_EXAMPLE_COM'), + Server::fromString('gitlab.example.com'), + ); + + $client->downloadArtifact($download); + $client->request('GET', $releases); + + Assert::same($server->sent[0]->getHeaderLine('private-token'), 'secret'); + Assert::same((string) $server->sent[1]->getUri(), 'https://maintainer.example/a.zip'); + Assert::same($server->sent[1]->getHeaderLine('private-token'), ''); + Assert::same((string) $server->sent[3]->getUri(), 'https://gitlab.example.com/api/v4/projects/7/releases'); + Assert::same($server->sent[3]->getHeaderLine('authorization'), 'Bearer secret'); + } +} diff --git a/tests/Unit/Module/Repository/Internal/GitLab/FactoryTest.php b/tests/Unit/Module/Repository/Internal/GitLab/FactoryTest.php index 8a25c348..1c7d5ec4 100644 --- a/tests/Unit/Module/Repository/Internal/GitLab/FactoryTest.php +++ b/tests/Unit/Module/Repository/Internal/GitLab/FactoryTest.php @@ -8,11 +8,16 @@ use Internal\DLoad\Module\Config\Schema\GitLab as GitLabConfig; use Internal\DLoad\Module\Registry\Internal\PassThroughRegistry; use Internal\DLoad\Module\Repository\Internal\GitLab\Factory; +use Internal\DLoad\Module\Repository\Internal\ServerTokens; +use Internal\DLoad\Module\Repository\Repository; use Internal\DLoad\Service\Logger; use Internal\DLoad\Tests\Unit\Module\Repository\Internal\GitLab\Stub\HttpFactoryStub; +use Internal\DLoad\Tests\Unit\Module\Repository\Stub\RecordingHttpFactory; +use Psr\Http\Message\RequestInterface; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Data\DataProvider; +use Testo\Expect; use Testo\Lifecycle\BeforeTest; use Testo\Test; @@ -20,6 +25,7 @@ final class FactoryTest { private Factory $factory; + private RecordingHttpFactory $http; public static function provideSupportedTypes(): \Generator { @@ -33,14 +39,69 @@ public static function provideSupportedTypes(): \Generator public static function provideRepositoryUris(): \Generator { yield 'bare project path' => ['group/project', 'group/project']; - yield 'full url' => ['https://gitlab.com/group/project', '/group/project']; - yield 'nested group' => ['https://gitlab.com/group/sub/project', '/group/sub/project']; - - # `parse_url()` returns null for a URL without a path and false for one it cannot parse at - # all; both have to fall back to the configured value rather than be passed on. - yield 'url without a path' => ['https://gitlab.com', 'https://gitlab.com']; - yield 'unparsable url' => ['http://:80', 'http://:80']; - yield 'scheme only' => ['https://', 'https://']; + yield 'nested path' => ['group/sub/project', 'group/sub/project']; + yield 'full url' => ['https://gitlab.com/group/project', 'group/project']; + yield 'nested group' => ['https://gitlab.com/group/sub/project', 'group/sub/project']; + yield 'public host without a scheme' => ['gitlab.com/group/project', 'group/project']; + yield 'clone url' => ['https://gitlab.com/group/project.git', 'group/project']; + yield 'trailing slash' => ['https://gitlab.example.com/group/project/', 'group/project']; + } + + public static function provideInvalidUris(): \Generator + { + yield 'single segment' => ['project']; + yield 'url without a path' => ['https://gitlab.com']; + yield 'unparsable url' => ['http://:80']; + yield 'scheme only' => ['https://']; + yield 'page of a project' => ['https://gitlab.example.com/group/project/-/releases']; + yield 'unsupported scheme' => ['ftp://gitlab.example.com/group/project']; + yield 'fragment' => ['https://gitlab.example.com/group/project#readme']; + } + + public static function provideTokenSources(): \Generator + { + yield 'no token' => ['group/project', [], null, '']; + yield 'GITLAB_TOKEN on the public host' => ['group/project', [], 'public', 'Bearer public']; + yield 'server variable wins over GITLAB_TOKEN' => [ + 'group/project', + ['DLOAD_TOKEN_GITLAB_COM' => 'own'], + 'public', + 'Bearer own', + ]; + yield 'GITLAB_TOKEN never leaves the public host' => ['https://gitlab.example.com/group/project', [], 'public', '']; + yield 'custom server variable' => [ + 'https://gitlab.example.com:8443/group/project', + ['DLOAD_TOKEN_GITLAB_EXAMPLE_COM___8443' => 'own'], + 'public', + 'Bearer own', + ]; + yield 'no token over plain http' => [ + 'http://gitlab.example.com/group/project', + ['DLOAD_TOKEN_GITLAB_EXAMPLE_COM' => 'own'], + null, + '', + ]; + yield 'plain http on loopback' => [ + 'http://localhost:8080/group/project', + ['DLOAD_TOKEN_LOCALHOST___8080' => 'local'], + null, + 'Bearer local', + ]; + } + + public static function provideServers(): \Generator + { + yield 'public' => ['group/project', 'https://gitlab.com/api/v4/projects/group%2Fproject/releases', 'gitlab:group/project']; + yield 'public url' => [ + 'https://gitlab.com/group/project', + 'https://gitlab.com/api/v4/projects/group%2Fproject/releases', + 'gitlab:group/project', + ]; + yield 'self-hosted' => [ + 'https://gitlab.example.com:8443/group/sub/project', + 'https://gitlab.example.com:8443/api/v4/projects/group%2Fsub%2Fproject/releases', + 'gitlab@gitlab.example.com:8443:group/sub/project', + ]; } #[DataProvider('provideSupportedTypes')] @@ -67,6 +128,46 @@ public function createDerivesTheProjectPathFromTheUri(string $uri, string $expec Assert::same($repository->getName(), $expectedPath); } + /** + * @param array $environment + */ + #[DataProvider('provideTokenSources')] + #[Test] + public function sendsTheTokenDeclaredForTheServer( + string $uri, + array $environment, + ?string $gitLabToken, + string $authorization, + ): void { + $factory = $this->recordingFactory($environment, $gitLabToken); + + $this->loadReleases($factory, $uri); + + Assert::same($this->lastRequest()->getHeaderLine('authorization'), $authorization); + } + + #[DataProvider('provideServers')] + #[Test] + public function talksToTheApiOfTheServer(string $uri, string $releasesUrl, string $id): void + { + $factory = $this->recordingFactory(); + + $repository = $this->loadReleases($factory, $uri); + + $uri = $this->lastRequest()->getUri(); + Assert::same($uri->getScheme() . '://' . $uri->getAuthority() . $uri->getPath(), $releasesUrl); + Assert::same((string) $repository->getId(), $id); + } + + #[DataProvider('provideInvalidUris')] + #[Test] + public function rejectsAnUriThatIsNotAProject(string $uri): never + { + Expect::exception(\InvalidArgumentException::class)->withMessageContaining("Repository `{$uri}`:"); + + $this->factory->create(self::config($uri)); + } + #[BeforeTest] protected function prepare(): void { @@ -75,6 +176,43 @@ protected function prepare(): void new GitLabConfig(), new Logger(), new PassThroughRegistry(), + new ServerTokens([]), ); } + + private static function config(string $uri): RepositoryConfig + { + $config = new RepositoryConfig(); + $config->type = 'gitlab'; + $config->uri = $uri; + + return $config; + } + + /** + * @param array $environment + */ + private function recordingFactory(array $environment = [], ?string $gitLabToken = null): Factory + { + $this->http = new RecordingHttpFactory(); + $gitLabConfig = new GitLabConfig(); + $gitLabConfig->token = $gitLabToken; + + return new Factory($this->http, $gitLabConfig, new Logger(), new PassThroughRegistry(), new ServerTokens($environment)); + } + + private function loadReleases(Factory $factory, string $uri): Repository + { + $repository = $factory->create(self::config($uri)); + \iterator_to_array($repository->getReleases()); + + return $repository; + } + + private function lastRequest(): RequestInterface + { + Assert::notBlank($this->http->sent); + + return $this->http->sent[\array_key_last($this->http->sent)]; + } } diff --git a/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php b/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php index 5f27f681..396b9a29 100644 --- a/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php +++ b/tests/Unit/Module/Repository/Internal/GitLab/GitLabRepositoryTest.php @@ -4,7 +4,6 @@ namespace Internal\DLoad\Tests\Unit\Module\Repository\Internal\GitLab; -use Internal\DLoad\Module\Config\Schema\GitLab as GitLabConfig; use Internal\DLoad\Module\HttpClient\Internal\NyholmFactoryImpl; use Internal\DLoad\Module\Registry\Internal\PassThroughRegistry; use Internal\DLoad\Module\Registry\Internal\StoredVersionRegistry; @@ -271,7 +270,7 @@ private static function createRepository( $logger = new Logger(); $httpFactory = new NyholmFactoryImpl($logger); $api = new RepositoryApi( - new Client($httpFactory, $client, new GitLabConfig()), + new Client($httpFactory, $client), $httpFactory, 'group/project', ); diff --git a/tests/Unit/Module/Repository/Internal/RepositoryLocationTest.php b/tests/Unit/Module/Repository/Internal/RepositoryLocationTest.php new file mode 100644 index 00000000..299d6808 --- /dev/null +++ b/tests/Unit/Module/Repository/Internal/RepositoryLocationTest.php @@ -0,0 +1,87 @@ + ['owner/repo', null, 'owner/repo']; + yield 'path with surrounding slashes and spaces' => [' /owner/repo/ ', null, 'owner/repo']; + yield 'nested path' => ['group/sub/project', null, 'group/sub/project']; + yield 'public url' => ['https://github.com/owner/repo', null, 'owner/repo']; + yield 'public url in upper case' => ['HTTPS://GitHub.COM/owner/repo', null, 'owner/repo']; + yield 'public url over http' => ['http://github.com/owner/repo', null, 'owner/repo']; + yield 'public url with the default port' => ['https://github.com:443/owner/repo', null, 'owner/repo']; + yield 'public host without a scheme' => ['github.com/owner/repo', null, 'owner/repo']; + yield 'clone url' => ['https://github.com/owner/repo.git', null, 'owner/repo']; + yield 'self-hosted' => ['https://ghe.example.com/owner/repo', 'https://ghe.example.com', 'owner/repo']; + yield 'self-hosted with a port' => [ + 'https://gitlab.example.com:8443/group/sub/project/', + 'https://gitlab.example.com:8443', + 'group/sub/project', + ]; + yield 'public host on another port' => ['https://github.com:8443/owner/repo', 'https://github.com:8443', 'owner/repo']; + yield 'subdomain of the public host' => ['https://www.github.com/owner/repo', 'https://www.github.com', 'owner/repo']; + yield 'loopback over http' => ['http://127.0.0.1:8080/owner/repo', 'http://127.0.0.1:8080', 'owner/repo']; + yield 'ipv6' => ['http://[::1]:8080/owner/repo', 'http://[::1]:8080', 'owner/repo']; + yield 'host without a scheme stays a path' => ['ghe.example.com/owner/repo', null, 'ghe.example.com/owner/repo']; + } + + public static function provideInvalidUris(): \Generator + { + yield 'only slashes' => ['/', 'no repository path']; + yield 'server without a path' => ['https://ghe.example.com', 'no repository path']; + yield 'unsupported scheme' => ['ftp://ghe.example.com/owner/repo', 'invalid server']; + yield 'credentials' => ['https://token@ghe.example.com/owner/repo', 'credentials are not allowed']; + yield 'no host' => ['https:///owner/repo', 'invalid server']; + yield 'query' => ['https://ghe.example.com/owner/repo?tab=readme', 'a query or fragment']; + yield 'fragment in a path' => ['owner/repo#readme', 'a query or fragment']; + yield 'page inside a project' => ['https://gitlab.example.com/group/project/-/releases', 'points inside a repository']; + yield 'empty segment' => ['owner//repo', 'malformed']; + yield 'dot segment' => ['owner/./repo', 'malformed']; + yield 'parent segment' => ['https://ghe.example.com/owner/../repo', 'malformed']; + yield 'nothing but .git' => ['owner/.git', 'malformed']; + } + + #[DataProvider('provideLocations')] + #[Test] + public function splitsTheUriIntoServerAndPath(string $uri, ?string $server, string $path): void + { + $location = RepositoryLocation::fromUri($uri, self::PUBLIC); + + Assert::same($location->server?->url(), $server); + Assert::same($location->path(), $path); + } + + #[DataProvider('provideInvalidUris')] + #[Test] + public function rejectsAnythingButARepositoryPathOrUrl(string $uri, string $reason): never + { + Expect::exception(\InvalidArgumentException::class)->withMessagePattern( + \sprintf('/^Repository `%s`: .*%s/', \preg_quote(\trim($uri), '/'), \preg_quote($reason, '/')), + ); + + RepositoryLocation::fromUri($uri, self::PUBLIC); + } + + #[Test] + public function invalidNamesTheRepository(): void + { + $error = RepositoryLocation::fromUri('owner/repo', self::PUBLIC)->invalid('expected something else.'); + + Assert::same($error->getMessage(), 'Repository `owner/repo`: expected something else.'); + } +} diff --git a/tests/Unit/Module/Repository/Internal/ServerTest.php b/tests/Unit/Module/Repository/Internal/ServerTest.php new file mode 100644 index 00000000..ab2f7cb7 --- /dev/null +++ b/tests/Unit/Module/Repository/Internal/ServerTest.php @@ -0,0 +1,150 @@ + ['ghe.example.com', 'https://ghe.example.com', 'ghe.example.com']; + yield 'host with port' => ['gitlab.example.com:8443', 'https://gitlab.example.com:8443', 'gitlab.example.com:8443']; + yield 'explicit https' => ['https://ghe.example.com', 'https://ghe.example.com', 'ghe.example.com']; + yield 'http' => ['http://127.0.0.1:8080', 'http://127.0.0.1:8080', '127.0.0.1:8080']; + yield 'upper case' => ['HTTPS://GHE.Example.COM', 'https://ghe.example.com', 'ghe.example.com']; + yield 'trailing slash' => ['https://ghe.example.com/', 'https://ghe.example.com', 'ghe.example.com']; + yield 'surrounding spaces' => [' ghe.example.com ', 'https://ghe.example.com', 'ghe.example.com']; + yield 'default https port' => ['ghe.example.com:443', 'https://ghe.example.com', 'ghe.example.com']; + yield 'default http port' => ['http://localhost:80', 'http://localhost', 'localhost']; + yield 'ipv6' => ['http://[::1]:8080', 'http://[::1]:8080', '[::1]:8080']; + yield 'punycode' => ['xn--80ak6aa92e.com', 'https://xn--80ak6aa92e.com', 'xn--80ak6aa92e.com']; + } + + public static function provideInvalidServers(): \Generator + { + yield 'empty' => ['']; + yield 'unsupported scheme' => ['ftp://example.com']; + yield 'path' => ['https://ghe.example.com/api/v3']; + yield 'query' => ['https://ghe.example.com?a=b']; + yield 'fragment' => ['https://ghe.example.com#top']; + yield 'credentials' => ['https://user:pass@ghe.example.com']; + yield 'malformed host' => ['https://ghe_example.com']; + yield 'port out of range' => ['ghe.example.com:70000']; + yield 'empty label' => ['ghe..example.com']; + yield 'label starting with a hyphen' => ['ghe.-example.com']; + yield 'label ending with a hyphen' => ['ghe-.example.com']; + yield 'trailing dot' => ['ghe.example.com.']; + yield 'malformed ipv6' => ['http://[:::1]']; + } + + public static function provideTokenVariables(): \Generator + { + yield 'public github' => ['github.com', 'DLOAD_TOKEN_GITHUB_COM']; + yield 'dots' => ['ghe.example.com', 'DLOAD_TOKEN_GHE_EXAMPLE_COM']; + yield 'hyphen' => ['git-hub.example.com', 'DLOAD_TOKEN_GIT__HUB_EXAMPLE_COM']; + yield 'double hyphen' => ['xn--80ak6aa92e.com', 'DLOAD_TOKEN_XN____80AK6AA92E_COM']; + yield 'port' => ['gitlab.example.com:8443', 'DLOAD_TOKEN_GITLAB_EXAMPLE_COM___8443']; + yield 'default port is not a part' => ['gitlab.example.com:443', 'DLOAD_TOKEN_GITLAB_EXAMPLE_COM']; + yield 'scheme is not a part' => ['http://127.0.0.1:8080', 'DLOAD_TOKEN_127_0_0_1___8080']; + yield 'ipv6' => ['http://[::1]:8080', 'DLOAD_TOKEN__0_0_0_0_0_0_0_1___8080']; + yield 'ipv6 in any spelling' => ['http://[0:0:0:0:0:0:0:1]:8080', 'DLOAD_TOKEN__0_0_0_0_0_0_0_1___8080']; + yield 'ipv6 with letters' => ['[2001:DB8::a:1]', 'DLOAD_TOKEN__2001_DB8_0_0_0_0_A_1']; + } + + /** + * Pairs of distinct servers that a lossy name would merge. + */ + public static function provideLookalikeServers(): \Generator + { + yield 'dot and hyphen' => ['ghe.example.com', 'ghe-example.com']; + yield 'dot and double hyphen' => ['a.b.c', 'a--b.c']; + yield 'hyphen and dot in other places' => ['a-b.c', 'a.b-c']; + yield 'port and label' => ['ghe.example.com:8443', 'ghe.example.com.8443']; + yield 'port and hyphen' => ['ghe.example.com:8443', 'ghe.example.com-8443']; + yield 'ipv6 and its last group' => ['[::1]:8080', '1:8080']; + yield 'ipv6 and a numeric host' => ['[::1]', '0.0.0.0.0.0.0.1']; + } + + public static function provideSecurity(): \Generator + { + yield 'https' => ['https://ghe.example.com', true]; + yield 'http on a remote host' => ['http://ghe.example.com', false]; + yield 'http on localhost' => ['http://localhost:8080', true]; + yield 'http on a localhost subdomain' => ['http://api.localhost', true]; + yield 'http on 127.0.0.1' => ['http://127.0.0.1:8080', true]; + yield 'http on another loopback address' => ['http://127.1.2.3', true]; + yield 'http on ipv6 loopback' => ['http://[::1]', true]; + yield 'http on a lookalike host' => ['http://127.0.0.1.example.com', false]; + } + + public static function provideServedUris(): \Generator + { + yield 'same server' => ['https://ghe.example.com', 'https://ghe.example.com/api/v3/repos/o/r', true]; + yield 'explicit default port' => ['https://ghe.example.com', 'https://ghe.example.com:443/o/r', true]; + yield 'host in another case' => ['https://ghe.example.com', 'https://GHE.example.com/o/r', true]; + yield 'same port' => ['ghe.example.com:8443', 'https://ghe.example.com:8443/o/r', true]; + yield 'another port' => ['ghe.example.com:8443', 'https://ghe.example.com/o/r', false]; + yield 'another scheme' => ['https://ghe.example.com', 'http://ghe.example.com/o/r', false]; + yield 'subdomain' => ['https://ghe.example.com', 'https://media.ghe.example.com/o/r', false]; + yield 'host as a path' => ['https://ghe.example.com', 'https://evil.example/ghe.example.com/o/r', false]; + yield 'host as a prefix' => ['https://ghe.example.com', 'https://ghe.example.com.evil.example/o/r', false]; + yield 'not a url' => ['https://ghe.example.com', 'ghe.example.com/o/r', false]; + } + + #[DataProvider('provideValidServers')] + #[Test] + public function fromStringNormalizesTheAddress(string $value, string $url, string $authority): void + { + $server = Server::fromString($value); + + Assert::same($server->url(), $url); + Assert::same($server->authority(), $authority); + Assert::same((string) $server, $url); + } + + #[DataProvider('provideInvalidServers')] + #[Test] + public function fromStringRejectsAnythingButAServerAddress(string $value): never + { + Expect::exception(\InvalidArgumentException::class)->withMessageContaining('Invalid server'); + + Server::fromString($value); + } + + #[DataProvider('provideTokenVariables')] + #[Test] + public function tokenVariableIsDerivedFromHostAndPort(string $value, string $variable): void + { + Assert::same(Server::fromString($value)->tokenVariable(), $variable); + } + + #[DataProvider('provideLookalikeServers')] + #[Test] + public function tokenVariableTellsLookalikeServersApart(string $a, string $b): void + { + Assert::notSame(Server::fromString($a)->tokenVariable(), Server::fromString($b)->tokenVariable()); + } + + #[DataProvider('provideSecurity')] + #[Test] + public function plainHttpIsSecureOnlyOnLoopback(string $value, bool $secure): void + { + Assert::same(Server::fromString($value)->isSecure(), $secure); + } + + #[DataProvider('provideServedUris')] + #[Test] + public function servesMatchesSchemeHostAndPortExactly(string $server, string $uri, bool $served): void + { + Assert::same(Server::fromString($server)->serves($uri), $served); + } +} diff --git a/tests/Unit/Module/Repository/Stub/RecordingHttpFactory.php b/tests/Unit/Module/Repository/Stub/RecordingHttpFactory.php new file mode 100644 index 00000000..8c3cd177 --- /dev/null +++ b/tests/Unit/Module/Repository/Stub/RecordingHttpFactory.php @@ -0,0 +1,54 @@ + */ + public array $sent = []; + + private readonly NyholmFactoryImpl $factory; + + public function __construct() + { + $this->factory = new NyholmFactoryImpl(new Logger()); + } + + public function uri(string $path, array $query = []): UriInterface + { + return $this->factory->uri($path, $query); + } + + public function request(string|Method $method, string|UriInterface $uri, array $headers = []): RequestInterface + { + return $this->factory->request($method, $uri, $headers); + } + + public function client(): ClientInterface + { + return $this; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->sent[] = $request; + + return ResponseStub::ok('[]'); + } +} diff --git a/tests/Unit/Module/Repository/Stub/RepositoryStub.php b/tests/Unit/Module/Repository/Stub/RepositoryStub.php index 837e05dc..19a1c5ea 100644 --- a/tests/Unit/Module/Repository/Stub/RepositoryStub.php +++ b/tests/Unit/Module/Repository/Stub/RepositoryStub.php @@ -4,6 +4,7 @@ namespace Internal\DLoad\Tests\Unit\Module\Repository\Stub; +use Internal\DLoad\Module\Registry\RepositoryId; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; use Internal\DLoad\Module\Repository\Internal\Collection; use Internal\DLoad\Module\Repository\ReleaseInterface; @@ -35,6 +36,11 @@ public function getName(): string return $this->name; } + public function getId(): RepositoryId + { + return new RepositoryId('github', $this->name); + } + public function getReleases(): ReleasesCollection { return $this->releases;