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;