Composer from the workspace's own repositories, and go get from g1t.sh
Composer - /-/composer/<workspace>/: packages.json, p2 metadata and ~dev, with notify-batch counting installs. Any repository with a root composer.json is a package under its name: semver tags are versions, branches dev- versions. Each version's composer.json, a git source and a zip dist, built from the commit on first request (export-ignore respected) and kept by digest. Pushing a tag publishes. - Kept current from git.push and repository events, with an hourly backfill and a sync_composer call. Access is the repository's: Basic (composer's http-basic with a g1t token) or Bearer. - repos: refs (branches and tags, annotated tags peeled), raw_file and raw_blobs for the packages service. - The site lists Composer packages with their README and install commands. Docs: the Composer guide. Go - Repository paths answer ?go-get=1 with go-import and go-source, so go get g1t.sh/<workspace>/<repo>/... works from git; private ones with GOPRIVATE and a token in .netrc. Docs: the Go guide. - Tree and blob pages take HEAD as the default branch (a 302), which go-source links use. Checked locally with the composer CLI: require, autoload, and update to a new tag after a push.
| 1013 | 1013 | "g1t-kit", | |
| 1014 | 1014 | "hex", | |
| 1015 | 1015 | "hmac 0.12.1", | |
| 1016 | + | "miniz_oxide", | |
| 1016 | 1017 | "serde", | |
| 1017 | 1018 | "serde_json", | |
| 1018 | 1019 | "sha1 0.10.7", | |
| 1024 | 1025 | name = "g1t-repos" | |
| 1025 | 1026 | version = "0.1.0" | |
| 1026 | 1027 | dependencies = [ | |
| 1028 | + | "base64 0.22.1", | |
| 1027 | 1029 | "futures-util", | |
| 1028 | 1030 | "g1t-contracts", | |
| 1029 | 1031 | "g1t-kit", |
| 76 | 76 | { label: 'Packages', slug: 'guides/packages' }, | |
| 77 | 77 | { label: 'Container images', slug: 'guides/containers' }, | |
| 78 | 78 | { label: 'npm', slug: 'guides/npm' }, | |
| 79 | + | { label: 'Composer', slug: 'guides/composer' }, | |
| 80 | + | { label: 'Go modules', slug: 'guides/go' }, | |
| 79 | 81 | { label: 'Secrets and variables', slug: 'guides/secrets-and-variables' }, | |
| 80 | 82 | { label: 'Security', slug: 'guides/security' }, | |
| 81 | 83 | ], |
| 1 | + | --- | |
| 2 | + | title: Composer | |
| 3 | + | description: Install a workspace's PHP packages with Composer, straight from its repositories on g1t.sh. Push a tag and it is released. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | Every workspace has a Composer repository of its own, made from its | |
| 7 | + | repositories: one with a `composer.json` at its root is a package. There | |
| 8 | + | is nothing to upload and nothing to keep in step. Push a tag and Composer | |
| 9 | + | can install it; push to a branch and its `dev-` version follows. | |
| 10 | + | ||
| 11 | + | ```text | |
| 12 | + | https://g1t.sh/-/composer/<workspace>/ | |
| 13 | + | ``` | |
| 14 | + | ||
| 15 | + | ## Make a repository a package | |
| 16 | + | ||
| 17 | + | Give the repository a `composer.json` at its root with a `name`: | |
| 18 | + | ||
| 19 | + | ```json | |
| 20 | + | { | |
| 21 | + | "name": "acme/http-client", | |
| 22 | + | "description": "Our HTTP client", | |
| 23 | + | "type": "library", | |
| 24 | + | "require": { "php": ">=8.1" }, | |
| 25 | + | "autoload": { "psr-4": { "Acme\Http\\": "src/" } } | |
| 26 | + | } | |
| 27 | + | ``` | |
| 28 | + | ||
| 29 | + | Push it to the default branch, and the package appears in the workspace's | |
| 30 | + | **Packages**, linked to the repository, with its README. Any vendor name | |
| 31 | + | works; each name is one package in a workspace. | |
| 32 | + | ||
| 33 | + | ## Versions | |
| 34 | + | ||
| 35 | + | | In git | Version | | |
| 36 | + | | --- | --- | | |
| 37 | + | | A tag that reads as a version: `v1.2.0`, `1.2.0`, `2.0.0-RC1`, `1.0.0-beta.2` | That version (`v1.2.0`) | | |
| 38 | + | | A branch | `dev-<branch>`; a branch named like a version, `1.x`, is `1.x-dev` | | |
| 39 | + | | The default branch | Its `dev-` version, marked as the default branch | | |
| 40 | + | ||
| 41 | + | Each version's requirements, autoloading and the rest are read from the | |
| 42 | + | `composer.json` at that tag or branch, so a version installs exactly what | |
| 43 | + | it was tagged with. Tags that do not read as versions, such as `nightly`, | |
| 44 | + | are left out. `extra.branch-alias` in the default branch's `composer.json` | |
| 45 | + | aliases it as usual (`"dev-main": "1.x-dev"`). | |
| 46 | + | ||
| 47 | + | To release, tag and push: | |
| 48 | + | ||
| 49 | + | ```sh | |
| 50 | + | git tag v1.3.0 | |
| 51 | + | git push origin v1.3.0 | |
| 52 | + | ``` | |
| 53 | + | ||
| 54 | + | Deleting a tag or branch takes its version away. | |
| 55 | + | ||
| 56 | + | ## Install | |
| 57 | + | ||
| 58 | + | Add the workspace's repository to your project, then require the package: | |
| 59 | + | ||
| 60 | + | ```sh | |
| 61 | + | composer config repositories.acme composer https://g1t.sh/-/composer/acme/ | |
| 62 | + | composer require acme/http-client | |
| 63 | + | ``` | |
| 64 | + | ||
| 65 | + | Packages install from a zip of the tag's commit, made the first time | |
| 66 | + | anyone asks for it and kept from then on. Files the repository's | |
| 67 | + | `.gitattributes` marks `export-ignore`, such as tests, are left out of it, | |
| 68 | + | as `git archive` leaves them out. `--prefer-source` clones from g1t.sh | |
| 69 | + | instead. | |
| 70 | + | ||
| 71 | + | ## Private packages | |
| 72 | + | ||
| 73 | + | A package has its repository's visibility and | |
| 74 | + | [roles](/guides/access-and-roles/): Read installs it. For a private one, | |
| 75 | + | give Composer your username and an | |
| 76 | + | [access token](https://g1t.sh/settings/tokens) (one with full access, or | |
| 77 | + | with `packages:read` and `code:read`, so that `--prefer-source` can clone | |
| 78 | + | too): | |
| 79 | + | ||
| 80 | + | ```sh | |
| 81 | + | composer config --global --auth http-basic.g1t.sh <you> <token> | |
| 82 | + | ``` | |
| 83 | + | ||
| 84 | + | That writes `~/.composer/auth.json`, which stays out of the project. A | |
| 85 | + | `Bearer` token works as well (`bearer.g1t.sh`). Without credentials, a | |
| 86 | + | workspace's repository lists only its public packages. | |
| 87 | + | ||
| 88 | + | ## In workflows | |
| 89 | + | ||
| 90 | + | A workflow's `G1T_TOKEN` can install the workspace's private packages: | |
| 91 | + | ||
| 92 | + | ```yaml | |
| 93 | + | jobs: | |
| 94 | + | test: | |
| 95 | + | runs-on: ubuntu-latest | |
| 96 | + | steps: | |
| 97 | + | - uses: actions/checkout@v4 | |
| 98 | + | - run: composer config --global --auth http-basic.g1t.sh g1t "$G1T_TOKEN" | |
| 99 | + | env: | |
| 100 | + | G1T_TOKEN: ${{ secrets.G1T_TOKEN }} | |
| 101 | + | - run: composer install --no-interaction | |
| 102 | + | ``` | |
| 103 | + | ||
| 104 | + | ## Limits | |
| 105 | + | ||
| 106 | + | A zip is made of at most 10,000 files and 64 MB; a larger commit installs | |
| 107 | + | from source (`composer install --prefer-source`). A package lists its | |
| 108 | + | newest 300 version tags and 50 branches. Zips count toward the workspace's | |
| 109 | + | package storage, as other packages do (see | |
| 110 | + | [storage and pull limits](/guides/containers/#storage-and-pull-limits)). |
| 1 | + | --- | |
| 2 | + | title: Go modules | |
| 3 | + | description: go get a workspace's Go modules straight from its repositories on g1t.sh, public and private. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | A repository on g1t.sh is a Go module at its own address. `go get` finds | |
| 7 | + | its code from the address, and fetches it with git: | |
| 8 | + | ||
| 9 | + | ```sh | |
| 10 | + | go get g1t.sh/acme/tools | |
| 11 | + | go get g1t.sh/acme/tools/cmd/lint@v1.4.0 | |
| 12 | + | ``` | |
| 13 | + | ||
| 14 | + | The repository's `go.mod` names the module: | |
| 15 | + | ||
| 16 | + | ```text | |
| 17 | + | module g1t.sh/acme/tools | |
| 18 | + | ``` | |
| 19 | + | ||
| 20 | + | Versions are its tags (`v1.4.0`); a pseudo-version names any other commit. | |
| 21 | + | Packages in subdirectories are imported by their path, as above. | |
| 22 | + | ||
| 23 | + | ## Public modules | |
| 24 | + | ||
| 25 | + | Public repositories need nothing: `go get` works as is, and the public Go | |
| 26 | + | module proxy and checksum database serve them like any other public module. | |
| 27 | + | ||
| 28 | + | ## Private modules | |
| 29 | + | ||
| 30 | + | Tell Go which modules are private, so it fetches them from g1t.sh directly | |
| 31 | + | and does not ask the public proxy or checksum database about them: | |
| 32 | + | ||
| 33 | + | ```sh | |
| 34 | + | go env -w GOPRIVATE=g1t.sh/acme | |
| 35 | + | ``` | |
| 36 | + | ||
| 37 | + | Then give git credentials for g1t.sh: your username and an | |
| 38 | + | [access token](https://g1t.sh/settings/tokens) (full access, or with | |
| 39 | + | `code:read`) in `~/.netrc` (`_netrc` on Windows): | |
| 40 | + | ||
| 41 | + | ```text | |
| 42 | + | machine g1t.sh | |
| 43 | + | login <you> | |
| 44 | + | password <token> | |
| 45 | + | ``` | |
| 46 | + | ||
| 47 | + | or with a git credential helper, as for any clone (see | |
| 48 | + | [Git](/guides/git/#authentication)). Reading a private module needs the | |
| 49 | + | Read role on its repository. | |
| 50 | + | ||
| 51 | + | Keep `GOINSECURE` and `GOFLAGS=-insecure` unset: g1t.sh is served over | |
| 52 | + | HTTPS, and neither is ever needed. | |
| 53 | + | ||
| 54 | + | ## In workflows | |
| 55 | + | ||
| 56 | + | ```yaml | |
| 57 | + | jobs: | |
| 58 | + | build: | |
| 59 | + | runs-on: ubuntu-latest | |
| 60 | + | env: | |
| 61 | + | GOPRIVATE: g1t.sh/acme | |
| 62 | + | steps: | |
| 63 | + | - uses: actions/checkout@v4 | |
| 64 | + | - uses: actions/setup-go@v5 | |
| 65 | + | with: | |
| 66 | + | go-version: stable | |
| 67 | + | - run: git config --global url."https://g1t:${G1T_TOKEN}@g1t.sh/".insteadOf "https://g1t.sh/" | |
| 68 | + | env: | |
| 69 | + | G1T_TOKEN: ${{ secrets.G1T_TOKEN }} | |
| 70 | + | - run: go build ./... | |
| 71 | + | ``` | |
| 72 | + | ||
| 73 | + | ## How it works | |
| 74 | + | ||
| 75 | + | Go asks `https://g1t.sh/<workspace>/<repo>?go-get=1` and reads a | |
| 76 | + | `go-import` tag pointing at `https://g1t.sh/<workspace>/<repo>.git`. Every | |
| 77 | + | repository address answers, private ones included, and the answer names | |
| 78 | + | nothing but that address; whether anything can be fetched is up to git and | |
| 79 | + | your credentials. |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | A workspace can publish packages to g1t and install them from it, beside | |
| 7 | − | the code they are built from: container images and npm packages, with | |
| 8 | − | Composer, Cargo and Go to follow. Each registry speaks its tool's own | |
| 9 | − | protocol, so `docker` and `npm` work with nothing but a login and an | |
| 10 | − | address. | |
| 7 | + | the code they are built from: container images, npm packages, Composer | |
| 8 | + | packages and Go modules, with Cargo to follow. Each registry speaks its | |
| 9 | + | tool's own protocol, so `docker`, `npm`, `composer` and `go` work with | |
| 10 | + | nothing but a login and an address. Composer packages and Go modules are | |
| 11 | + | read from the workspace's repositories: there is nothing to upload. | |
| 11 | 12 | ||
| 12 | 13 | | Registry | Address | Guide | | |
| 13 | 14 | | --- | --- | --- | | |
| 14 | 15 | | Container images | `g1t.sh/<workspace>/<name>` | [Container images](/guides/containers/) | | |
| 15 | 16 | | npm | `https://g1t.sh/-/npm/`, for the scope `@<workspace>` | [npm](/guides/npm/) | | |
| 17 | + | | Composer | `https://g1t.sh/-/composer/<workspace>/`, from the workspace's repositories | [Composer](/guides/composer/) | | |
| 18 | + | | Go | `g1t.sh/<workspace>/<repo>`, straight from git | [Go modules](/guides/go/) | | |
| 16 | 19 | ||
| 17 | 20 | ## Names | |
| 18 | 21 |
| 22 | 22 | try { | |
| 23 | 23 | const repo = await repos.get(path, viewer); | |
| 24 | 24 | if (!repo.ok) return; | |
| 25 | − | const now = await repos.resolveBranch(repo.value.id, ref); | |
| 25 | + | // `HEAD` is the default branch, as git means it (Go's go-source links use it). | |
| 26 | + | const now = ref === "HEAD" ? repo.value.defaultBranch : await repos.resolveBranch(repo.value.id, ref); | |
| 26 | 27 | if (!now || now === ref) return; | |
| 27 | 28 | const url = new URL(request.url); | |
| 28 | 29 | to = renamedBranchPath(url.pathname, url.search, now); | |
| 30 | 31 | // A lookup that fails leaves the page a 404, never a 500. | |
| 31 | 32 | return; | |
| 32 | 33 | } | |
| 33 | − | if (to) throw redirect(to, 301); | |
| 34 | + | // A rename is for good; what HEAD names can change. | |
| 35 | + | if (to) throw redirect(to, ref === "HEAD" ? 302 : 301); | |
| 34 | 36 | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { goImport } from "./go-get.ts"; | |
| 5 | + | ||
| 6 | + | test("go get finds a repository's code from its address and any package in it", () => { | |
| 7 | + | for (const path of ["/acme/lib", "/acme/lib/", "/acme/lib/pkg/sub", "/acme/lib.git"]) { | |
| 8 | + | const page = goImport(new URL(`https://g1t.sh${path}?go-get=1`)); | |
| 9 | + | assert.ok(page, path); | |
| 10 | + | assert.match(page, /<meta name="go-import" content="g1t\.sh\/acme\/lib git https:\/\/g1t\.sh\/acme\/lib\.git">/); | |
| 11 | + | assert.match(page, /<meta name="go-source" content="g1t\.sh\/acme\/lib https:\/\/g1t\.sh\/acme\/lib /); | |
| 12 | + | } | |
| 13 | + | }); | |
| 14 | + | ||
| 15 | + | test("only go-get requests for a repository's address are answered", () => { | |
| 16 | + | assert.equal(goImport(new URL("https://g1t.sh/acme/lib")), null, "no go-get"); | |
| 17 | + | assert.equal(goImport(new URL("https://g1t.sh/acme/lib?go-get=0")), null); | |
| 18 | + | assert.equal(goImport(new URL("https://g1t.sh/acme?go-get=1")), null, "a workspace is not a module"); | |
| 19 | + | assert.equal(goImport(new URL("https://g1t.sh/acme/-/packages?go-get=1")), null, "workspace pages"); | |
| 20 | + | assert.equal(goImport(new URL("https://g1t.sh/Acme/lib?go-get=1")), null); | |
| 21 | + | assert.equal(goImport(new URL("https://g1t.sh/acme/%22%3E?go-get=1")), null, "nothing is put in the page unchecked"); | |
| 22 | + | }); |
| 1 | + | /** | |
| 2 | + | * `go get g1t.sh/<workspace>/<repo>[/<package>]`: Go asks the address with | |
| 3 | + | * `?go-get=1` and reads where the module's code is from a `go-import` meta | |
| 4 | + | * tag. Every repository answers, public or private, without looking it up: | |
| 5 | + | * the answer only says where git would find it, and git then asks for | |
| 6 | + | * credentials (GOPRIVATE and .netrc) as it would for a clone. | |
| 7 | + | */ | |
| 8 | + | ||
| 9 | + | const WORKSPACE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/; | |
| 10 | + | const REPO = /^[a-z0-9._-]{1,100}$/; | |
| 11 | + | ||
| 12 | + | /** The page Go reads for `url`, or null when it is not a go-get request for a repository. */ | |
| 13 | + | export function goImport(url: URL): string | null { | |
| 14 | + | if (url.searchParams.get("go-get") !== "1") return null; | |
| 15 | + | const [workspace, rawRepo] = url.pathname.split("/").filter(Boolean); | |
| 16 | + | if (!workspace || !rawRepo) return null; | |
| 17 | + | const repo = rawRepo.replace(/\.git$/, ""); | |
| 18 | + | if (!WORKSPACE.test(workspace) || !REPO.test(repo) || repo.startsWith(".") || workspace === "-" || repo === "-") return null; | |
| 19 | + | const host = url.host; | |
| 20 | + | const prefix = `${host}/${workspace}/${repo}`; | |
| 21 | + | const home = `${url.protocol}//${prefix}`; | |
| 22 | + | return [ | |
| 23 | + | "<!doctype html>", | |
| 24 | + | "<html><head>", | |
| 25 | + | `<meta name="go-import" content="${prefix} git ${home}.git">`, | |
| 26 | + | `<meta name="go-source" content="${prefix} ${home} ${home}/tree/HEAD{/dir} ${home}/blob/HEAD{/dir}/{file}#L{line}">`, | |
| 27 | + | "</head><body>", | |
| 28 | + | `go get ${prefix}`, | |
| 29 | + | "</body></html>", | |
| 30 | + | "", | |
| 31 | + | ].join("\n"); | |
| 32 | + | } |
| 25 | 25 | assert.equal(installCommands(pkg, null, "ada").install, "npm install @acme/ui"); | |
| 26 | 26 | }); | |
| 27 | 27 | ||
| 28 | + | test("a Composer package is required after its workspace's repository is added, with credentials for private ones", () => { | |
| 29 | + | const pkg = { ecosystem: "composer" as const, address: "g1t.sh/-/composer/acme/acme/lib", name: "acme/lib", workspace: "acme" }; | |
| 30 | + | assert.deepEqual(installCommands(pkg, "v1.1.0", "ada"), { | |
| 31 | + | registry: "composer config repositories.acme composer https://g1t.sh/-/composer/acme/", | |
| 32 | + | login: "composer config --global --auth http-basic.g1t.sh ada YOUR_TOKEN", | |
| 33 | + | install: "composer require acme/lib:v1.1.0", | |
| 34 | + | }); | |
| 35 | + | }); | |
| 36 | + | ||
| 28 | 37 | test("a container image is pulled by its address and tag", () => { | |
| 29 | 38 | const pkg = { ecosystem: "container" as const, address: "g1t.sh/acme/web", name: "web", workspace: "acme" }; | |
| 30 | 39 | assert.deepEqual(installCommands(pkg, "latest", "ada"), { |
| 62 | 62 | install: `npm install @${pkg.workspace}/${pkg.name}${version ? `@${version}` : ""}`, | |
| 63 | 63 | }; | |
| 64 | 64 | case "composer": | |
| 65 | + | // The workspace's repository (in composer.json, needed for any | |
| 66 | + | // install), then the credentials a private package also needs. | |
| 65 | 67 | return { | |
| 66 | − | login: `composer config repositories.${pkg.workspace} composer https://${host}/-/composer/${pkg.workspace}/`, | |
| 68 | + | registry: `composer config repositories.${pkg.workspace} composer https://${host}/-/composer/${pkg.workspace}/`, | |
| 69 | + | login: `composer config --global --auth http-basic.${host} ${you} YOUR_TOKEN`, | |
| 67 | 70 | install: `composer require ${pkg.name}${version ? `:${version}` : ""}`, | |
| 68 | 71 | }; | |
| 69 | 72 | case "cargo": |
| 35 | 35 | } | |
| 36 | 36 | }); | |
| 37 | 37 | ||
| 38 | + | test("the Composer registries go to the packages service", () => { | |
| 39 | + | for (const path of [ | |
| 40 | + | "/-/composer/acme/packages.json", | |
| 41 | + | "/-/composer/acme/p2/acme/lib.json", | |
| 42 | + | "/-/composer/acme/p2/acme/lib~dev.json", | |
| 43 | + | `/-/composer/acme/dist/acme/lib/${"a".repeat(40)}.zip`, | |
| 44 | + | ]) { | |
| 45 | + | assert.equal(servicePath(path), "packages", path); | |
| 46 | + | } | |
| 47 | + | assert.equal(servicePath("/-/composer"), null); | |
| 48 | + | }); | |
| 49 | + | ||
| 38 | 50 | test("git goes to repos, and everything else is the site's", () => { | |
| 39 | 51 | assert.equal(servicePath("/acme/web.git/info/refs"), "git"); | |
| 40 | 52 | assert.equal(servicePath("/acme/web/git-receive-pack"), "git"); |
| 9 | 9 | const REGISTRY_PATH = /^\/v2(?:\/|$)/; | |
| 10 | 10 | /** The npm registry: `/-/npm/`, which `.npmrc` names for a workspace's scope. */ | |
| 11 | 11 | const NPM_PATH = /^\/-\/npm(?:\/|$)/; | |
| 12 | + | /** The Composer registries: `/-/composer/<workspace>/`, one per workspace. */ | |
| 13 | + | const COMPOSER_PATH = /^\/-\/composer\//; | |
| 12 | 14 | ||
| 13 | 15 | export type ServicePath = "git" | "packages" | null; | |
| 14 | 16 | ||
| 15 | 17 | export function servicePath(pathname: string): ServicePath { | |
| 16 | − | if (REGISTRY_PATH.test(pathname) || NPM_PATH.test(pathname)) return "packages"; | |
| 18 | + | if (REGISTRY_PATH.test(pathname) || NPM_PATH.test(pathname) || COMPOSER_PATH.test(pathname)) return "packages"; | |
| 17 | 19 | if (GIT_PATH.test(pathname)) return "git"; | |
| 18 | 20 | return null; | |
| 19 | 21 | } |
| 73 | 73 | const outcome = actionData as Outcome | undefined; | |
| 74 | 74 | const latest = tags.find((tag) => tag.tag === "latest")?.tag ?? tags[0]?.tag ?? null; | |
| 75 | 75 | const commands = installCommands(pkg, latest, username); | |
| 76 | − | const npm = pkg.ecosystem === "npm"; | |
| 76 | + | // npm and Composer versions are numbers; images are digests and tags. | |
| 77 | + | const npm = pkg.ecosystem !== "container"; | |
| 78 | + | // Composer's versions are the repository's tags and branches: they | |
| 79 | + | // change in git, not here. | |
| 80 | + | const fromGit = pkg.ecosystem === "composer"; | |
| 77 | 81 | // Signatures and attestations hang off the images they describe. | |
| 78 | 82 | const images = versions.filter((version) => !version.subject); | |
| 79 | 83 | const attached = (digest: string) => versions.filter((version) => version.subject === digest); | |
| 116 | 120 | {commands.registry && <CopyLine prompt text={commands.registry} />} | |
| 117 | 121 | {pkg.visibility === "private" && <CopyLine prompt text={commands.login} />} | |
| 118 | 122 | <CopyLine prompt text={commands.install} /> | |
| 119 | − | {npm && pkg.visibility === "private" && ( | |
| 123 | + | {commands.registry && pkg.visibility === "private" && ( | |
| 120 | 124 | <p className="text-xs text-faint"> | |
| 121 | 125 | Put an{" "} | |
| 122 | 126 | <Link to="/settings/tokens" className="text-muted hover:text-fg"> | |
| 146 | 150 | <h2 className="text-sm font-semibold"> | |
| 147 | 151 | Versions <span className="font-normal text-faint">{images.length}</span> | |
| 148 | 152 | </h2> | |
| 153 | + | {fromGit && ( | |
| 154 | + | <p className="text-xs text-faint"> | |
| 155 | + | Each tag of {pkg.repo ? `${pkg.repo.namespace}/${pkg.repo.name}` : "its repository"} that reads as a version, and each | |
| 156 | + | branch as <code className="font-mono">dev-</code>, from its composer.json. Push a tag to publish one; delete it to take it away. | |
| 157 | + | </p> | |
| 158 | + | )} | |
| 149 | 159 | {images.length === 0 ? ( | |
| 150 | 160 | <p className="text-sm text-muted">No versions are left.</p> | |
| 151 | 161 | ) : ( | |
| 152 | 162 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 153 | 163 | {images.map((version) => ( | |
| 154 | − | <VersionRow key={version.id} version={version} attached={attached(version.digest)} canDelete={permissions.delete} npm={npm} /> | |
| 164 | + | <VersionRow | |
| 165 | + | key={version.id} | |
| 166 | + | version={version} | |
| 167 | + | attached={attached(version.digest)} | |
| 168 | + | canDelete={permissions.delete && !fromGit} | |
| 169 | + | npm={npm} | |
| 170 | + | /> | |
| 155 | 171 | ))} | |
| 156 | 172 | </ul> | |
| 157 | 173 | )} |
| 1 | 1 | import { createRequestHandler } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import { finishResponse, withRequestPerf } from "../app/lib/perf.server"; | |
| 4 | + | import { goImport } from "../app/lib/go-get"; | |
| 4 | 5 | import { servicePath } from "../app/lib/registry-paths"; | |
| 5 | 6 | ||
| 6 | 7 | const requestHandler = createRequestHandler( | |
| 36 | 37 | // The container registry (`docker login g1t.sh`) and the npm registry | |
| 37 | 38 | // (`g1t.sh/-/npm/`) are the packages | |
| 38 | 39 | // service's, handed over the same way. | |
| 40 | + | // `go get g1t.sh/<workspace>/<repo>`: where its code is, from the | |
| 41 | + | // address alone, so it costs nothing and caches. | |
| 42 | + | const go = request.method === "GET" ? goImport(new URL(request.url)) : null; | |
| 43 | + | if (go) { | |
| 44 | + | return new Response(go, { | |
| 45 | + | headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" }, | |
| 46 | + | }); | |
| 47 | + | } | |
| 39 | 48 | const service = servicePath(pathname); | |
| 40 | 49 | if (service === "git") { | |
| 41 | 50 | return proxyGit(env, request); |
| 243 | 243 | pub workspace: String, | |
| 244 | 244 | } | |
| 245 | 245 | ||
| 246 | + | /// `sync_composer`: read a repository's Composer package again now, as a | |
| 247 | + | /// push would: made, updated or deleted from its branches, tags and | |
| 248 | + | /// `composer.json`. Returns `bool`: whether it is a package. | |
| 249 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 250 | + | pub struct SyncComposerArgs { | |
| 251 | + | pub repo_id: String, | |
| 252 | + | } | |
| 253 | + | ||
| 246 | 254 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 247 | 255 | pub struct PackageStorage { | |
| 248 | 256 | pub public_bytes: u64, | |
| 281 | 289 | for ecosystem in Ecosystem::ALL { | |
| 282 | 290 | assert!(ts.contains(&format!("\"{}\"", ecosystem.as_str())), "{}", ecosystem.as_str()); | |
| 283 | 291 | } | |
| 284 | − | for method in ["list_packages", "get_package", "delete_version", "delete_package", "set_package", "storage", "storage_all"] { | |
| 292 | + | for method in ["list_packages", "get_package", "delete_version", "delete_package", "set_package", "storage", "storage_all", "sync_composer"] { | |
| 285 | 293 | assert!(ts.contains(&format!("\"{method}\"")), "{method}"); | |
| 286 | 294 | } | |
| 287 | 295 | } |
| 808 | 808 | /// The most blobs one `read_blobs` call reads. | |
| 809 | 809 | pub const MAX_READ_BLOBS: usize = 100; | |
| 810 | 810 | ||
| 811 | + | /// `refs`: a repository's branches and tags with the commit each points to | |
| 812 | + | /// (annotated tags peeled), for services that follow them, such as the | |
| 813 | + | /// packages service's Composer registry. No viewer: g1t's own services | |
| 814 | + | /// only. Returns `Option<RepoRefs>`, null for a fork or an unknown id. | |
| 815 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 816 | + | #[serde(rename_all = "camelCase")] | |
| 817 | + | pub struct RefsArgs { | |
| 818 | + | pub repo_id: String, | |
| 819 | + | } | |
| 820 | + | ||
| 821 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 822 | + | pub struct GitRefEntry { | |
| 823 | + | /// The full ref: `refs/heads/main`, `refs/tags/v1.0.0`. | |
| 824 | + | pub name: String, | |
| 825 | + | pub commit: String, | |
| 826 | + | } | |
| 827 | + | ||
| 828 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 829 | + | pub struct RepoRefs { | |
| 830 | + | pub repo: Repo, | |
| 831 | + | pub refs: Vec<GitRefEntry>, | |
| 832 | + | } | |
| 833 | + | ||
| 834 | + | /// `raw_file`: one file's bytes at a ref or commit, base64, for g1t's own | |
| 835 | + | /// services (no viewer). Returns `Option<RawFile>`: null when the file is | |
| 836 | + | /// missing or larger than `max_bytes`. | |
| 837 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 838 | + | #[serde(rename_all = "camelCase")] | |
| 839 | + | pub struct RawFileArgs { | |
| 840 | + | pub repo_id: String, | |
| 841 | + | #[serde(rename = "ref")] | |
| 842 | + | pub git_ref: String, | |
| 843 | + | pub path: String, | |
| 844 | + | pub max_bytes: u32, | |
| 845 | + | } | |
| 846 | + | ||
| 847 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 848 | + | pub struct RawFile { | |
| 849 | + | pub size: u64, | |
| 850 | + | /// Standard base64. | |
| 851 | + | pub data: String, | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | /// `raw_blobs`: blobs' bytes, base64, in the order asked, at most | |
| 855 | + | /// [`MAX_READ_BLOBS`]; `data` is null for one missing or larger than | |
| 856 | + | /// `max_bytes`. For g1t's own services. Returns `Vec<RawBlob>`. | |
| 857 | + | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 858 | + | #[serde(rename_all = "camelCase")] | |
| 859 | + | pub struct RawBlobsArgs { | |
| 860 | + | pub repo_id: String, | |
| 861 | + | pub hashes: Vec<String>, | |
| 862 | + | pub max_bytes: u32, | |
| 863 | + | } | |
| 864 | + | ||
| 865 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 866 | + | pub struct RawBlob { | |
| 867 | + | pub hash: String, | |
| 868 | + | pub size: u64, | |
| 869 | + | pub data: Option<String>, | |
| 870 | + | } | |
| 871 | + | ||
| 811 | 872 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 812 | 873 | pub struct BlobText { | |
| 813 | 874 | pub hash: String, |
| 610 | 610 | }), | |
| 611 | 611 | storage: (workspace) => call("storage", { workspace }), | |
| 612 | 612 | storageAll: () => call("storage_all", {}), | |
| 613 | + | syncComposer: (repoId) => call("sync_composer", { repo_id: repoId }), | |
| 613 | 614 | }; | |
| 614 | 615 | } | |
| 615 | 616 |
| 109 | 109 | storage(workspace: string): Promise<PackageStorage>; | |
| 110 | 110 | /** For billing: every workspace with packages, from one query. */ | |
| 111 | 111 | storageAll(): Promise<WorkspacePackageStorage[]>; | |
| 112 | + | /** Read a repository's Composer package again now, as a push would. Whether it is one. */ | |
| 113 | + | syncComposer(repoId: string): Promise<boolean>; | |
| 112 | 114 | }; | |
| 113 | 115 | ||
| 114 | 116 | /** The RPC method behind each call, as the Rust service names them. */ | |
| 115 | − | export const PACKAGES_METHODS = ["list_packages", "get_package", "delete_version", "delete_package", "set_package", "storage", "storage_all"] as const; | |
| 117 | + | export const PACKAGES_METHODS = ["list_packages", "get_package", "delete_version", "delete_package", "set_package", "storage", "storage_all", "sync_composer"] as const; |
| 20 | 20 | hmac = "0.12" | |
| 21 | 21 | sha2 = { version = "0.10", features = ["compress"] } | |
| 22 | 22 | sha1 = "0.10" | |
| 23 | + | miniz_oxide = "0.9" | |
| 23 | 24 | ||
| 24 | 25 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the | |
| 25 | 26 | # time (docs/DEPLOYING.md, "Build speed"). |
| 6 | 6 | // outsider, `bo` (OUTSIDER_TOKEN). | |
| 7 | 7 | // - Repos knows two repositories of acme: `web` (private) and `site` | |
| 8 | 8 | // (public). | |
| 9 | + | // - Repos also keeps a small git store for `lib` (rep_lib), a private PHP | |
| 10 | + | // library: tags v1.0.0 and v1.1.0, branches main and feature, for the | |
| 11 | + | // Composer registry (refs, raw_file, raw_blobs, list_files, all_ids). | |
| 12 | + | // Signing in with the password `g1t_push` (any request with Basic | |
| 13 | + | // credentials) tags v1.2.0 on a new commit, as a push would; then call | |
| 14 | + | // `sync_composer` as the git.push event would. | |
| 9 | 15 | // - Events takes every event and audit entry and logs them. | |
| 10 | 16 | // - Billing says acme is free, with FREE_PRIVATE_BYTES of private package | |
| 11 | 17 | // storage and 10 GB public. | |
| 13 | 19 | const REPOS = { | |
| 14 | 20 | web: { isPrivate: true }, | |
| 15 | 21 | site: { isPrivate: false }, | |
| 22 | + | lib: { isPrivate: true }, | |
| 16 | 23 | }; | |
| 17 | 24 | ||
| 25 | + | // The library's commits: each its files. Hashes are made up, 40 hex each. | |
| 26 | + | const composerJson = JSON.stringify( | |
| 27 | + | { | |
| 28 | + | name: "acme/lib", | |
| 29 | + | description: "Greets people", | |
| 30 | + | type: "library", | |
| 31 | + | license: "MIT", | |
| 32 | + | require: { php: ">=8.1" }, | |
| 33 | + | autoload: { "psr-4": { "Acme\\Lib\\": "src/" } }, | |
| 34 | + | "require-dev": { "phpunit/phpunit": "^11" }, | |
| 35 | + | }, | |
| 36 | + | null, | |
| 37 | + | 2, | |
| 38 | + | ); | |
| 39 | + | const greeter = (body) => `<?php\n\nnamespace Acme\\Lib;\n\nfinal class Greeter\n{\n${body}\n}\n`; | |
| 40 | + | const COMMITS = { | |
| 41 | + | ["1".repeat(40)]: { | |
| 42 | + | "composer.json": composerJson, | |
| 43 | + | "src/Greeter.php": greeter(' public function hello(string $name): string { return "Hello, $name!"; }'), | |
| 44 | + | "README.md": "# acme/lib\n\nGreets people.\n", | |
| 45 | + | ".gitattributes": "/tests export-ignore\n", | |
| 46 | + | "tests/GreeterTest.php": "<?php // not in the archive\n", | |
| 47 | + | }, | |
| 48 | + | ["2".repeat(40)]: { | |
| 49 | + | "composer.json": composerJson, | |
| 50 | + | "src/Greeter.php": greeter( | |
| 51 | + | ' public function hello(string $name): string { return "Hello, $name!"; }\n public function bye(string $name): string { return "Bye, $name."; }', | |
| 52 | + | ), | |
| 53 | + | "README.md": "# acme/lib\n\nGreets people, and says goodbye.\n", | |
| 54 | + | ".gitattributes": "/tests export-ignore\n", | |
| 55 | + | "tests/GreeterTest.php": "<?php // not in the archive\n", | |
| 56 | + | }, | |
| 57 | + | ["3".repeat(40)]: { "composer.json": composerJson, "src/Greeter.php": greeter(" // a feature in progress") }, | |
| 58 | + | ["4".repeat(40)]: { | |
| 59 | + | "composer.json": composerJson, | |
| 60 | + | "src/Greeter.php": greeter( | |
| 61 | + | ' public function hello(string $name): string { return "Hi, $name!"; }\n public function bye(string $name): string { return "Bye, $name."; }', | |
| 62 | + | ), | |
| 63 | + | "README.md": "# acme/lib\n\nv1.2.\n", | |
| 64 | + | }, | |
| 65 | + | }; | |
| 66 | + | const REFS = [ | |
| 67 | + | { name: "refs/heads/main", commit: "2".repeat(40) }, | |
| 68 | + | { name: "refs/heads/feature", commit: "3".repeat(40) }, | |
| 69 | + | { name: "refs/tags/v1.0.0", commit: "1".repeat(40) }, | |
| 70 | + | { name: "refs/tags/v1.1.0", commit: "2".repeat(40) }, | |
| 71 | + | ]; | |
| 72 | + | const blobHash = (commit, path) => `${commit.slice(0, 8)}${Buffer.from(path).toString("hex")}`; | |
| 73 | + | const base64 = (text) => Buffer.from(text).toString("base64"); | |
| 74 | + | function blob(hash) { | |
| 75 | + | for (const [commit, files] of Object.entries(COMMITS)) { | |
| 76 | + | for (const [path, text] of Object.entries(files)) if (blobHash(commit, path) === hash) return text; | |
| 77 | + | } | |
| 78 | + | return null; | |
| 79 | + | } | |
| 80 | + | const libRepo = () => ({ | |
| 81 | + | id: "rep_lib", | |
| 82 | + | namespace: "acme", | |
| 83 | + | name: "lib", | |
| 84 | + | description: null, | |
| 85 | + | isPrivate: true, | |
| 86 | + | ownerId: "usr_dev", | |
| 87 | + | defaultBranch: "main", | |
| 88 | + | forkOf: null, | |
| 89 | + | createdAt: "2026-10-01T00:00:00.000Z", | |
| 90 | + | }); | |
| 91 | + | ||
| 92 | + | function push() { | |
| 93 | + | if (!REFS.some((r) => r.name === "refs/tags/v1.2.0")) { | |
| 94 | + | REFS.push({ name: "refs/tags/v1.2.0", commit: "4".repeat(40) }); | |
| 95 | + | REFS[0].commit = "4".repeat(40); | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 18 | 99 | function user(env, secret) { | |
| 100 | + | if (secret === "g1t_push") { | |
| 101 | + | push(); | |
| 102 | + | return null; | |
| 103 | + | } | |
| 19 | 104 | if (secret === env.DEV_TOKEN) { | |
| 20 | 105 | return { | |
| 21 | 106 | id: "usr_dev", | |
| 47 | 132 | const method = new URL(request.url).pathname.replace(/^\/rpc\//, ""); | |
| 48 | 133 | const args = await request.json().catch(() => ({})); | |
| 49 | 134 | const json = (value) => Response.json(value); | |
| 135 | + | ||
| 50 | 136 | switch (method) { | |
| 137 | + | case "refs": | |
| 138 | + | return json(args.repoId === "rep_lib" ? { repo: libRepo(), refs: REFS } : null); | |
| 139 | + | case "raw_file": { | |
| 140 | + | const commit = REFS.find((r) => r.name === args.ref || r.name === `refs/heads/${args.ref}`)?.commit ?? args.ref; | |
| 141 | + | const text = args.repoId === "rep_lib" ? COMMITS[commit]?.[args.path] : undefined; | |
| 142 | + | return json(text === undefined ? null : { size: text.length, data: base64(text) }); | |
| 143 | + | } | |
| 144 | + | case "list_files": { | |
| 145 | + | const files = args.repoId === "rep_lib" ? COMMITS[args.ref] : undefined; | |
| 146 | + | return json({ | |
| 147 | + | commit: files ? args.ref : null, | |
| 148 | + | files: Object.keys(files ?? {}).map((path) => ({ path, hash: blobHash(args.ref, path) })), | |
| 149 | + | truncated: false, | |
| 150 | + | }); | |
| 151 | + | } | |
| 152 | + | case "raw_blobs": | |
| 153 | + | return json( | |
| 154 | + | (args.hashes ?? []).map((hash) => { | |
| 155 | + | const text = blob(hash); | |
| 156 | + | return { hash, size: text?.length ?? 0, data: text === null ? null : base64(text) }; | |
| 157 | + | }), | |
| 158 | + | ); | |
| 159 | + | case "all_ids": | |
| 160 | + | return json(args.after ? { ids: [], next: null } : { ids: ["rep_web", "rep_lib"], next: null }); | |
| 51 | 161 | case "user_for_git_credentials": | |
| 52 | 162 | return json(user(env, args.secret)); | |
| 53 | 163 | case "get": { |
| 3 | 3 | "name": "g1t-packages-stubs", | |
| 4 | 4 | "main": "stubs.js", | |
| 5 | 5 | "compatibility_date": "2026-09-26", | |
| 6 | + | "compatibility_flags": ["nodejs_compat"], | |
| 6 | 7 | "vars": { "DEV_TOKEN": "g1t_devtoken", "MEMBER_TOKEN": "g1t_member", "OUTSIDER_TOKEN": "g1t_outsider", "FREE_PRIVATE_BYTES": "50000000" } | |
| 7 | 8 | } |
| 1 | + | -- Composer packages are found in the workspace's repositories, not | |
| 2 | + | -- uploaded: the backfill walks every repository once, a page an hour, so | |
| 3 | + | -- repositories that had a composer.json before the registry existed are | |
| 4 | + | -- found without waiting for their next push. | |
| 5 | + | CREATE TABLE composer_backfill ( | |
| 6 | + | key TEXT PRIMARY KEY, | |
| 7 | + | -- The last repository id done. | |
| 8 | + | after TEXT, | |
| 9 | + | finished_at TEXT | |
| 10 | + | ); | |
| 11 | + | CREATE INDEX versions_commit ON versions (package_id, digest); |
| 1 | + | //! What the Composer registry needs that does not touch the network: | |
| 2 | + | //! package names, versions read from tags and branches as Composer reads | |
| 3 | + | //! them, the metadata Composer installs from, `export-ignore`, and zips. | |
| 4 | + | //! | |
| 5 | + | //! A Composer package is not uploaded: it is a repository of the workspace | |
| 6 | + | //! with a `composer.json` at its root. Each tag that reads as a version is a | |
| 7 | + | //! version, and each branch a `dev-` one, with the metadata of that ref's | |
| 8 | + | //! `composer.json`, a git source on g1t.sh, and a zip of the commit as its | |
| 9 | + | //! dist, made when it is first asked for. | |
| 10 | + | ||
| 11 | + | use serde_json::{Map, Value, json}; | |
| 12 | + | ||
| 13 | + | /// Composer's rule for a package name: `vendor/name`, lowercase, words | |
| 14 | + | /// joined by `.`, `_` or `-`. | |
| 15 | + | pub fn valid_name(name: &str) -> bool { | |
| 16 | + | let Some((vendor, project)) = name.split_once('/') else { | |
| 17 | + | return false; | |
| 18 | + | }; | |
| 19 | + | let part = |text: &str, double_dash: bool| { | |
| 20 | + | let bytes = text.as_bytes(); | |
| 21 | + | if bytes.is_empty() || !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() { | |
| 22 | + | return false; | |
| 23 | + | } | |
| 24 | + | let mut run = String::new(); | |
| 25 | + | for byte in bytes { | |
| 26 | + | if byte.is_ascii_lowercase() || byte.is_ascii_digit() { | |
| 27 | + | if !run.is_empty() && !(run == "." || run == "_" || run == "-" || (double_dash && run == "--")) { | |
| 28 | + | return false; | |
| 29 | + | } | |
| 30 | + | run.clear(); | |
| 31 | + | } else if matches!(byte, b'.' | b'_' | b'-') { | |
| 32 | + | run.push(*byte as char); | |
| 33 | + | } else { | |
| 34 | + | return false; | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | true | |
| 38 | + | }; | |
| 39 | + | !project.contains('/') && part(vendor, false) && part(project, true) && name.len() <= 200 | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// A version Composer can install, as Packagist names it: the tag or | |
| 43 | + | /// branch's own `version`, and `version_normalized` to compare by. | |
| 44 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 45 | + | pub struct Version { | |
| 46 | + | pub version: String, | |
| 47 | + | pub normalized: String, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | impl Version { | |
| 51 | + | pub fn is_dev(&self) -> bool { | |
| 52 | + | self.normalized.starts_with("dev-") || self.normalized.ends_with("-dev") | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// The stability words Composer reads after a version, as it writes them. | |
| 57 | + | fn stability(word: &str) -> Option<&'static str> { | |
| 58 | + | match word.to_ascii_lowercase().as_str() { | |
| 59 | + | "stable" => Some(""), | |
| 60 | + | "beta" | "b" => Some("beta"), | |
| 61 | + | "rc" => Some("RC"), | |
| 62 | + | "alpha" | "a" => Some("alpha"), | |
| 63 | + | "patch" | "pl" | "p" => Some("patch"), | |
| 64 | + | _ => None, | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | /// A tag's version, the way Composer's version parser reads it: | |
| 69 | + | /// `v1.2.3`, `1.2`, `1.0.0-beta.2`, `2.0.0-RC1`. Anything else is not a | |
| 70 | + | /// version, and its tag is left out. | |
| 71 | + | pub fn tag_version(tag: &str) -> Option<Version> { | |
| 72 | + | let text = tag.strip_prefix('v').or_else(|| tag.strip_prefix('V')).unwrap_or(tag); | |
| 73 | + | let digits_end = text.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(text.len()); | |
| 74 | + | let (numbers, rest) = text.split_at(digits_end); | |
| 75 | + | let numbers = numbers.trim_end_matches('.'); | |
| 76 | + | let parts: Vec<&str> = numbers.split('.').collect(); | |
| 77 | + | if parts.is_empty() || parts.len() > 4 || parts.iter().any(|p| p.is_empty() || p.len() > 9) { | |
| 78 | + | return None; | |
| 79 | + | } | |
| 80 | + | if numbers.len() != text[..digits_end].len() && !rest.is_empty() { | |
| 81 | + | // `1.2.` followed by more is not a version. | |
| 82 | + | return None; | |
| 83 | + | } | |
| 84 | + | let mut normalized: Vec<String> = parts.iter().map(|p| p.trim_start_matches('0').to_owned()).map(|p| if p.is_empty() { "0".into() } else { p }).collect(); | |
| 85 | + | while normalized.len() < 4 { | |
| 86 | + | normalized.push("0".into()); | |
| 87 | + | } | |
| 88 | + | let mut normalized = normalized.join("."); | |
| 89 | + | let rest = rest.trim_start_matches(['-', '_', '.']); | |
| 90 | + | if !rest.is_empty() { | |
| 91 | + | let (word, tail) = rest.split_at(rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len())); | |
| 92 | + | let word = stability(word)?; | |
| 93 | + | let number = tail.trim_start_matches(['-', '.']); | |
| 94 | + | let (number, dev) = match number.strip_suffix("dev") { | |
| 95 | + | Some(n) => (n.trim_end_matches(['-', '.']), true), | |
| 96 | + | None => (number, false), | |
| 97 | + | }; | |
| 98 | + | if !number.bytes().all(|b| b.is_ascii_digit() || b == b'.') { | |
| 99 | + | return None; | |
| 100 | + | } | |
| 101 | + | if !word.is_empty() { | |
| 102 | + | normalized.push('-'); | |
| 103 | + | normalized.push_str(word); | |
| 104 | + | normalized.push_str(&number.replace('.', "")); | |
| 105 | + | } | |
| 106 | + | if dev { | |
| 107 | + | normalized.push_str("-dev"); | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | Some(Version { version: tag.to_owned(), normalized }) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// A branch's version: `dev-<branch>`, or for a branch named like a | |
| 114 | + | /// version (`1.x`, `2.1`) that version's `-dev`, as Composer reads them. | |
| 115 | + | pub fn branch_version(branch: &str) -> Version { | |
| 116 | + | let text = branch.strip_prefix('v').unwrap_or(branch); | |
| 117 | + | let parts: Vec<&str> = text.split('.').collect(); | |
| 118 | + | let numeric = !text.is_empty() | |
| 119 | + | && parts.len() <= 4 | |
| 120 | + | && parts[0].bytes().all(|b| b.is_ascii_digit()) | |
| 121 | + | && !parts[0].is_empty() | |
| 122 | + | && parts.iter().all(|p| !p.is_empty() && (p.bytes().all(|b| b.is_ascii_digit()) || matches!(*p, "x" | "X" | "*"))); | |
| 123 | + | if !numeric { | |
| 124 | + | return Version { version: format!("dev-{branch}"), normalized: format!("dev-{branch}") }; | |
| 125 | + | } | |
| 126 | + | let mut nines: Vec<String> = parts | |
| 127 | + | .iter() | |
| 128 | + | .map(|p| if p.bytes().all(|b| b.is_ascii_digit()) { (*p).to_owned() } else { "9999999".to_owned() }) | |
| 129 | + | .collect(); | |
| 130 | + | while nines.len() < 4 { | |
| 131 | + | nines.push("9999999".to_owned()); | |
| 132 | + | } | |
| 133 | + | let version = if parts.last().is_some_and(|p| matches!(*p, "x" | "X" | "*")) { | |
| 134 | + | format!("{branch}-dev") | |
| 135 | + | } else { | |
| 136 | + | format!("{branch}.x-dev") | |
| 137 | + | }; | |
| 138 | + | Version { version, normalized: format!("{}-dev", nines.join(".")) } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// The fields of `composer.json` a version's metadata keeps, as Packagist | |
| 142 | + | /// serves them. | |
| 143 | + | const KEPT: [&str; 25] = [ | |
| 144 | + | "name", | |
| 145 | + | "description", | |
| 146 | + | "keywords", | |
| 147 | + | "homepage", | |
| 148 | + | "readme", | |
| 149 | + | "license", | |
| 150 | + | "authors", | |
| 151 | + | "type", | |
| 152 | + | "support", | |
| 153 | + | "funding", | |
| 154 | + | "autoload", | |
| 155 | + | "autoload-dev", | |
| 156 | + | "extra", | |
| 157 | + | "bin", | |
| 158 | + | "include-path", | |
| 159 | + | "target-dir", | |
| 160 | + | "require", | |
| 161 | + | "require-dev", | |
| 162 | + | "suggest", | |
| 163 | + | "provide", | |
| 164 | + | "replace", | |
| 165 | + | "conflict", | |
| 166 | + | "archive", | |
| 167 | + | "abandoned", | |
| 168 | + | "notification-url", | |
| 169 | + | ]; | |
| 170 | + | ||
| 171 | + | /// Where one version installs from. | |
| 172 | + | pub struct Origin<'a> { | |
| 173 | + | /// `https://g1t.sh/acme/lib.git`. | |
| 174 | + | pub git_url: &'a str, | |
| 175 | + | /// `https://g1t.sh/-/composer/acme/dist/acme/lib/<commit>.zip`. | |
| 176 | + | pub dist_url: &'a str, | |
| 177 | + | pub commit: &'a str, | |
| 178 | + | /// Set on the default branch's version, as Composer 2 marks it. | |
| 179 | + | pub default_branch: bool, | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// A version's metadata: its `composer.json`, kept fields only, named | |
| 183 | + | /// `name`, with its version, source and dist. | |
| 184 | + | pub fn version_entry(composer: &Value, name: &str, version: &Version, origin: &Origin<'_>) -> Value { | |
| 185 | + | let mut entry = Map::new(); | |
| 186 | + | if let Value::Object(fields) = composer { | |
| 187 | + | for key in KEPT { | |
| 188 | + | if let Some(value) = fields.get(key) { | |
| 189 | + | entry.insert(key.to_owned(), value.clone()); | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | // A single license is a list of one, as Packagist writes it. | |
| 194 | + | if let Some(Value::String(license)) = entry.get("license") { | |
| 195 | + | let license = license.clone(); | |
| 196 | + | entry.insert("license".into(), json!([license])); | |
| 197 | + | } | |
| 198 | + | entry.entry("type").or_insert(json!("library")); | |
| 199 | + | entry.insert("name".into(), json!(name)); | |
| 200 | + | entry.insert("version".into(), json!(version.version)); | |
| 201 | + | entry.insert("version_normalized".into(), json!(version.normalized)); | |
| 202 | + | entry.insert("source".into(), json!({ "type": "git", "url": origin.git_url, "reference": origin.commit })); | |
| 203 | + | entry.insert( | |
| 204 | + | "dist".into(), | |
| 205 | + | json!({ "type": "zip", "url": origin.dist_url, "reference": origin.commit, "shasum": "" }), | |
| 206 | + | ); | |
| 207 | + | if origin.default_branch { | |
| 208 | + | entry.insert("default-branch".into(), json!(true)); | |
| 209 | + | } | |
| 210 | + | Value::Object(entry) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | /// `p2/<vendor>/<name>.json`: every version, as Composer reads them | |
| 214 | + | /// (not minified: each entry whole). | |
| 215 | + | pub fn p2(name: &str, versions: &[Value]) -> Value { | |
| 216 | + | json!({ "packages": { name: versions } }) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// `packages.json` of a workspace. | |
| 220 | + | pub fn root(workspace: &str, available: &[String]) -> Value { | |
| 221 | + | json!({ | |
| 222 | + | "packages": [], | |
| 223 | + | "metadata-url": format!("/-/composer/{workspace}/p2/%package%.json"), | |
| 224 | + | "available-packages": available, | |
| 225 | + | "notify-batch": format!("/-/composer/{workspace}/downloads"), | |
| 226 | + | }) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | /// Whether `pattern` matches `text`, `*` any run of characters but `/`, | |
| 230 | + | /// `?` any one. | |
| 231 | + | fn glob(pattern: &[u8], text: &[u8]) -> bool { | |
| 232 | + | match (pattern.first(), text.first()) { | |
| 233 | + | (None, None) => true, | |
| 234 | + | (Some(b'*'), _) => { | |
| 235 | + | glob(&pattern[1..], text) || (!text.is_empty() && text[0] != b'/' && glob(pattern, &text[1..])) | |
| 236 | + | } | |
| 237 | + | (Some(b'?'), Some(c)) if *c != b'/' => glob(&pattern[1..], &text[1..]), | |
| 238 | + | (Some(p), Some(c)) if p == c => glob(&pattern[1..], &text[1..]), | |
| 239 | + | _ => false, | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | /// The patterns a `.gitattributes` marks `export-ignore`. | |
| 244 | + | pub fn export_ignores(gitattributes: &str) -> Vec<String> { | |
| 245 | + | gitattributes | |
| 246 | + | .lines() | |
| 247 | + | .filter_map(|line| { | |
| 248 | + | let line = line.trim(); | |
| 249 | + | if line.starts_with('#') { | |
| 250 | + | return None; | |
| 251 | + | } | |
| 252 | + | let mut words = line.split_whitespace(); | |
| 253 | + | let pattern = words.next()?; | |
| 254 | + | words.any(|attr| attr == "export-ignore").then(|| pattern.to_owned()) | |
| 255 | + | }) | |
| 256 | + | .collect() | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | /// Whether a file is left out of an archive by an `export-ignore` | |
| 260 | + | /// pattern: a pattern without `/` matches a name at any depth, one with a | |
| 261 | + | /// `/` matches from the root, and a directory's pattern everything in it. | |
| 262 | + | pub fn ignored(patterns: &[String], path: &str) -> bool { | |
| 263 | + | patterns.iter().any(|pattern| { | |
| 264 | + | let (pattern, dir_only) = match pattern.strip_suffix('/') { | |
| 265 | + | Some(p) => (p, true), | |
| 266 | + | None => (pattern.as_str(), false), | |
| 267 | + | }; | |
| 268 | + | let anchored = pattern.contains('/'); | |
| 269 | + | let pattern = pattern.trim_start_matches('/'); | |
| 270 | + | let parts: Vec<&str> = path.split('/').collect(); | |
| 271 | + | // Each directory the file is in, and (unless only directories | |
| 272 | + | // match) the file itself. | |
| 273 | + | let candidates = (1..=parts.len()).filter(|n| !dir_only || *n < parts.len()); | |
| 274 | + | for n in candidates { | |
| 275 | + | let prefix = parts[..n].join("/"); | |
| 276 | + | let subject = if anchored { prefix.as_str() } else { parts[n - 1] }; | |
| 277 | + | if glob(pattern.as_bytes(), subject.as_bytes()) { | |
| 278 | + | return true; | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | false | |
| 282 | + | }) | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | /// CRC-32, as zip records each file's. | |
| 286 | + | fn crc32(bytes: &[u8]) -> u32 { | |
| 287 | + | let mut table = [0u32; 256]; | |
| 288 | + | for (i, entry) in table.iter_mut().enumerate() { | |
| 289 | + | let mut c = i as u32; | |
| 290 | + | for _ in 0..8 { | |
| 291 | + | c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; | |
| 292 | + | } | |
| 293 | + | *entry = c; | |
| 294 | + | } | |
| 295 | + | let mut crc = 0xFFFF_FFFFu32; | |
| 296 | + | for byte in bytes { | |
| 297 | + | crc = table[((crc ^ u32::from(*byte)) & 0xFF) as usize] ^ (crc >> 8); | |
| 298 | + | } | |
| 299 | + | !crc | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | /// A zip of `files` (path and bytes), deflated where that is smaller. No | |
| 303 | + | /// directory entries, a fixed time, so the same files make the same zip. | |
| 304 | + | pub fn zip(files: &[(String, Vec<u8>)]) -> Vec<u8> { | |
| 305 | + | let mut out = Vec::new(); | |
| 306 | + | let mut central = Vec::new(); | |
| 307 | + | for (path, data) in files { | |
| 308 | + | let crc = crc32(data); | |
| 309 | + | let deflated = miniz_oxide::deflate::compress_to_vec(data, 6); | |
| 310 | + | let (method, body): (u16, &[u8]) = if deflated.len() < data.len() { (8, &deflated) } else { (0, data) }; | |
| 311 | + | let offset = out.len() as u32; | |
| 312 | + | let name = path.as_bytes(); | |
| 313 | + | let header = |sig: u32, central_entry: bool| { | |
| 314 | + | let mut h = Vec::with_capacity(46 + name.len()); | |
| 315 | + | h.extend_from_slice(&sig.to_le_bytes()); | |
| 316 | + | if central_entry { | |
| 317 | + | h.extend_from_slice(&0x031Eu16.to_le_bytes()); // made by: Unix, 3.0 | |
| 318 | + | } | |
| 319 | + | h.extend_from_slice(&20u16.to_le_bytes()); // version needed | |
| 320 | + | h.extend_from_slice(&0x0800u16.to_le_bytes()); // UTF-8 names | |
| 321 | + | h.extend_from_slice(&method.to_le_bytes()); | |
| 322 | + | h.extend_from_slice(&0u16.to_le_bytes()); // time | |
| 323 | + | h.extend_from_slice(&0x0021u16.to_le_bytes()); // date: 1980-01-01 | |
| 324 | + | h.extend_from_slice(&crc.to_le_bytes()); | |
| 325 | + | h.extend_from_slice(&(body.len() as u32).to_le_bytes()); | |
| 326 | + | h.extend_from_slice(&(data.len() as u32).to_le_bytes()); | |
| 327 | + | h.extend_from_slice(&(name.len() as u16).to_le_bytes()); | |
| 328 | + | h.extend_from_slice(&0u16.to_le_bytes()); // extra | |
| 329 | + | if central_entry { | |
| 330 | + | h.extend_from_slice(&0u16.to_le_bytes()); // comment | |
| 331 | + | h.extend_from_slice(&0u16.to_le_bytes()); // disk | |
| 332 | + | h.extend_from_slice(&0u16.to_le_bytes()); // internal attributes | |
| 333 | + | h.extend_from_slice(&(0o100644u32 << 16).to_le_bytes()); | |
| 334 | + | h.extend_from_slice(&offset.to_le_bytes()); | |
| 335 | + | } | |
| 336 | + | h.extend_from_slice(name); | |
| 337 | + | h | |
| 338 | + | }; | |
| 339 | + | out.extend_from_slice(&header(0x0403_4b50, false)); | |
| 340 | + | out.extend_from_slice(body); | |
| 341 | + | central.extend_from_slice(&header(0x0201_4b50, true)); | |
| 342 | + | } | |
| 343 | + | let central_offset = out.len() as u32; | |
| 344 | + | out.extend_from_slice(¢ral); | |
| 345 | + | out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); | |
| 346 | + | out.extend_from_slice(&[0, 0, 0, 0]); // disks | |
| 347 | + | out.extend_from_slice(&(files.len() as u16).to_le_bytes()); | |
| 348 | + | out.extend_from_slice(&(files.len() as u16).to_le_bytes()); | |
| 349 | + | out.extend_from_slice(&(central.len() as u32).to_le_bytes()); | |
| 350 | + | out.extend_from_slice(¢ral_offset.to_le_bytes()); | |
| 351 | + | out.extend_from_slice(&0u16.to_le_bytes()); | |
| 352 | + | out | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | #[cfg(test)] | |
| 356 | + | mod tests { | |
| 357 | + | use super::*; | |
| 358 | + | ||
| 359 | + | #[test] | |
| 360 | + | fn names_follow_composers_rule() { | |
| 361 | + | for good in ["acme/lib", "acme-co/http.client", "a1/b_2", "acme/my--lib"] { | |
| 362 | + | assert!(valid_name(good), "{good}"); | |
| 363 | + | } | |
| 364 | + | for bad in ["acme", "Acme/lib", "acme/lib/x", "-acme/lib", "acme/lib-", "acme/l b", "acme/a..b", "ac--me/lib"] { | |
| 365 | + | assert!(!valid_name(bad), "{bad}"); | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[test] | |
| 370 | + | fn tags_read_as_composer_reads_them() { | |
| 371 | + | let v = |tag: &str| tag_version(tag).map(|v| v.normalized); | |
| 372 | + | assert_eq!(v("v1.2.3").as_deref(), Some("1.2.3.0")); | |
| 373 | + | assert_eq!(v("1.2").as_deref(), Some("1.2.0.0")); | |
| 374 | + | assert_eq!(v("2.0.0-RC1").as_deref(), Some("2.0.0.0-RC1")); | |
| 375 | + | assert_eq!(v("1.0.0-beta.2").as_deref(), Some("1.0.0.0-beta2")); | |
| 376 | + | assert_eq!(v("1.0.0-alpha").as_deref(), Some("1.0.0.0-alpha")); | |
| 377 | + | assert_eq!(v("1.0.0-p1").as_deref(), Some("1.0.0.0-patch1")); | |
| 378 | + | assert_eq!(v("1.0.0-stable").as_deref(), Some("1.0.0.0")); | |
| 379 | + | assert_eq!(v("01.02.003").as_deref(), Some("1.2.3.0")); | |
| 380 | + | assert_eq!(tag_version("v1.0.0").unwrap().version, "v1.0.0", "the tag's own name is the version"); | |
| 381 | + | for bad in ["latest", "release-1", "1.0.0-nope", "1.2.3.4.5", "v", ""] { | |
| 382 | + | assert_eq!(tag_version(bad), None, "{bad}"); | |
| 383 | + | } | |
| 384 | + | assert!(!tag_version("1.0.0").unwrap().is_dev()); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | #[test] | |
| 388 | + | fn branches_are_dev_versions() { | |
| 389 | + | assert_eq!(branch_version("main"), Version { version: "dev-main".into(), normalized: "dev-main".into() }); | |
| 390 | + | assert_eq!( | |
| 391 | + | branch_version("1.x"), | |
| 392 | + | Version { version: "1.x-dev".into(), normalized: "1.9999999.9999999.9999999-dev".into() } | |
| 393 | + | ); | |
| 394 | + | assert_eq!( | |
| 395 | + | branch_version("2.1"), | |
| 396 | + | Version { version: "2.1.x-dev".into(), normalized: "2.1.9999999.9999999-dev".into() } | |
| 397 | + | ); | |
| 398 | + | assert_eq!(branch_version("feature/x").version, "dev-feature/x"); | |
| 399 | + | assert!(branch_version("main").is_dev()); | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | #[test] | |
| 403 | + | fn a_versions_metadata_is_its_composer_json_with_where_it_installs_from() { | |
| 404 | + | let composer = json!({ | |
| 405 | + | "name": "acme/lib", | |
| 406 | + | "description": "A library", | |
| 407 | + | "license": "MIT", | |
| 408 | + | "require": { "php": ">=8.1" }, | |
| 409 | + | "autoload": { "psr-4": { "Acme\\Lib\\": "src/" } }, | |
| 410 | + | "scripts": { "test": "phpunit" }, | |
| 411 | + | "config": { "sort-packages": true }, | |
| 412 | + | }); | |
| 413 | + | let version = tag_version("v1.0.0").unwrap(); | |
| 414 | + | let origin = Origin { | |
| 415 | + | git_url: "https://g1t.sh/acme/lib.git", | |
| 416 | + | dist_url: "https://g1t.sh/-/composer/acme/dist/acme/lib/abc.zip", | |
| 417 | + | commit: "abc", | |
| 418 | + | default_branch: false, | |
| 419 | + | }; | |
| 420 | + | let entry = version_entry(&composer, "acme/lib", &version, &origin); | |
| 421 | + | assert_eq!(entry["version"], "v1.0.0"); | |
| 422 | + | assert_eq!(entry["version_normalized"], "1.0.0.0"); | |
| 423 | + | assert_eq!(entry["license"], json!(["MIT"])); | |
| 424 | + | assert_eq!(entry["type"], "library"); | |
| 425 | + | assert_eq!(entry["require"]["php"], ">=8.1"); | |
| 426 | + | assert_eq!(entry["autoload"]["psr-4"]["Acme\\Lib\\"], "src/"); | |
| 427 | + | assert_eq!(entry["source"], json!({ "type": "git", "url": "https://g1t.sh/acme/lib.git", "reference": "abc" })); | |
| 428 | + | assert_eq!(entry["dist"]["type"], "zip"); | |
| 429 | + | assert_eq!(entry["dist"]["reference"], "abc"); | |
| 430 | + | assert!(entry.get("scripts").is_none(), "only what installs"); | |
| 431 | + | assert!(entry.get("config").is_none()); | |
| 432 | + | assert!(entry.get("default-branch").is_none()); | |
| 433 | + | let p2 = p2("acme/lib", &[entry]); | |
| 434 | + | assert_eq!(p2["packages"]["acme/lib"][0]["version"], "v1.0.0"); | |
| 435 | + | let root = root("acme", &["acme/lib".to_owned()]); | |
| 436 | + | assert_eq!(root["metadata-url"], "/-/composer/acme/p2/%package%.json"); | |
| 437 | + | assert_eq!(root["available-packages"], json!(["acme/lib"])); | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | #[test] | |
| 441 | + | fn export_ignore_leaves_files_out_as_git_archive_does() { | |
| 442 | + | let patterns = export_ignores("# dev only\n/tests export-ignore\n.github/ export-ignore\n*.md export-ignore\n/phpunit.xml.dist export-ignore\nsrc/* text\n"); | |
| 443 | + | assert_eq!(patterns, ["/tests", ".github/", "*.md", "/phpunit.xml.dist"]); | |
| 444 | + | assert!(ignored(&patterns, "tests/LibTest.php")); | |
| 445 | + | assert!(ignored(&patterns, ".github/workflows/ci.yml")); | |
| 446 | + | assert!(ignored(&patterns, "README.md")); | |
| 447 | + | assert!(ignored(&patterns, "docs/guide.md")); | |
| 448 | + | assert!(ignored(&patterns, "phpunit.xml.dist")); | |
| 449 | + | assert!(!ignored(&patterns, "src/Lib.php")); | |
| 450 | + | assert!(!ignored(&patterns, "src/tests/Helper.php"), "/tests is anchored at the root"); | |
| 451 | + | assert!(!ignored(&patterns, "composer.json")); | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | #[test] | |
| 455 | + | fn a_zip_holds_its_files_and_their_checksums() { | |
| 456 | + | assert_eq!(crc32(b"123456789"), 0xCBF4_3926); | |
| 457 | + | let files = vec![ | |
| 458 | + | ("composer.json".to_owned(), br#"{"name":"acme/lib"}"#.to_vec()), | |
| 459 | + | ("src/Lib.php".to_owned(), "<?php\n".repeat(100).into_bytes()), | |
| 460 | + | ]; | |
| 461 | + | let zip = zip(&files); | |
| 462 | + | assert_eq!(&zip[..4], &0x0403_4b50u32.to_le_bytes()); | |
| 463 | + | // The end record counts both files. | |
| 464 | + | let end = &zip[zip.len() - 22..]; | |
| 465 | + | assert_eq!(&end[..4], &0x0605_4b50u32.to_le_bytes()); | |
| 466 | + | assert_eq!(u16::from_le_bytes([end[10], end[11]]), 2); | |
| 467 | + | // The repetitive file was deflated, the short one stored. | |
| 468 | + | assert!(zip.len() < 600, "{}", zip.len()); | |
| 469 | + | assert_eq!(super::zip(&files), zip, "the same files make the same zip"); | |
| 470 | + | } | |
| 471 | + | } |
| 1 | + | //! The Composer registry: `g1t.sh/-/composer/<workspace>/`, one per | |
| 2 | + | //! workspace, built from its repositories (composer.rs says how). | |
| 3 | + | //! | |
| 4 | + | //! ```sh | |
| 5 | + | //! composer config repositories.acme composer https://g1t.sh/-/composer/acme/ | |
| 6 | + | //! composer config --global --auth http-basic.g1t.sh <you> <g1t token> | |
| 7 | + | //! composer require acme/lib | |
| 8 | + | //! ``` | |
| 9 | + | //! | |
| 10 | + | //! Nothing is uploaded. A repository's versions are read again when it is | |
| 11 | + | //! pushed to (`git.push`), restored, renamed or moved, and once for every | |
| 12 | + | //! repository by the backfill; a request only reads what that left. A zip | |
| 13 | + | //! of a commit is made the first time it is asked for, and kept by its | |
| 14 | + | //! digest like every file here. | |
| 15 | + | ||
| 16 | + | use std::collections::{HashMap, HashSet}; | |
| 17 | + | ||
| 18 | + | use base64::Engine; | |
| 19 | + | use base64::engine::general_purpose::STANDARD; | |
| 20 | + | use g1t_contracts::User; | |
| 21 | + | use g1t_contracts::events::PackageEvent; | |
| 22 | + | use g1t_contracts::new_id; | |
| 23 | + | use g1t_contracts::repos::{ | |
| 24 | + | AllIdsArgs, FileList, IdPage, ListFilesArgs, MAX_LISTED_FILES, MAX_READ_BLOBS, RawBlob, RawBlobsArgs, RawFile, RawFileArgs, RefsArgs, | |
| 25 | + | RepoRefs, | |
| 26 | + | }; | |
| 27 | + | use g1t_kit::now_ms; | |
| 28 | + | use serde_json::{Value, json}; | |
| 29 | + | use worker::{Context, Headers, Method, Request, Response, Result, Url}; | |
| 30 | + | ||
| 31 | + | use crate::access::{self, Action}; | |
| 32 | + | use crate::composer::{self, Origin, Version}; | |
| 33 | + | use crate::db::{NewVersion, PackageRow}; | |
| 34 | + | use crate::digest::Digest; | |
| 35 | + | use crate::oci::{Credentials, origin}; | |
| 36 | + | use crate::store::BlobStore; | |
| 37 | + | use crate::{Caller, Packages, TargetOf}; | |
| 38 | + | ||
| 39 | + | const COMPOSER: &str = "composer"; | |
| 40 | + | /// The biggest `composer.json` or README read. | |
| 41 | + | const MAX_FILE_BYTES: u32 = 1024 * 1024; | |
| 42 | + | /// The most branches and tags a repository's package lists. | |
| 43 | + | const MAX_BRANCHES: usize = 50; | |
| 44 | + | const MAX_TAGS: usize = 300; | |
| 45 | + | /// The most a zip may hold before it is made, in all and per file. | |
| 46 | + | const MAX_ARCHIVE_BYTES: u64 = 64 * 1024 * 1024; | |
| 47 | + | const MAX_ARCHIVED_FILE: u32 = 32 * 1024 * 1024; | |
| 48 | + | /// Repositories the backfill reads each hour. | |
| 49 | + | const BACKFILL_PAGE: u32 = 25; | |
| 50 | + | const README_NAMES: [&str; 4] = ["README.md", "readme.md", "README.markdown", "README"]; | |
| 51 | + | ||
| 52 | + | fn error(status: u16, message: impl Into<String>) -> Result<Response> { | |
| 53 | + | let mut response = Response::from_json(&json!({ "status": "error", "message": message.into() }))?.with_status(status); | |
| 54 | + | if status == 401 { | |
| 55 | + | response.headers_mut().set("www-authenticate", "Basic realm=\"g1t\"")?; | |
| 56 | + | } | |
| 57 | + | Ok(response) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// One of the registry's endpoints, under `/-/composer/<workspace>/`. | |
| 61 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 62 | + | pub enum ComposerRoute { | |
| 63 | + | Root { workspace: String }, | |
| 64 | + | /// `p2/<vendor>/<name>.json`, or `~dev.json` for the branches. | |
| 65 | + | Metadata { workspace: String, name: String, dev: bool }, | |
| 66 | + | Dist { workspace: String, name: String, commit: String }, | |
| 67 | + | Downloads { workspace: String }, | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | pub fn route(path: &str) -> Option<ComposerRoute> { | |
| 71 | + | let rest = path.strip_prefix("/-/composer/")?; | |
| 72 | + | let (workspace, rest) = rest.split_once('/')?; | |
| 73 | + | let workspace = workspace.to_ascii_lowercase(); | |
| 74 | + | if rest == "packages.json" || rest.is_empty() { | |
| 75 | + | return Some(ComposerRoute::Root { workspace }); | |
| 76 | + | } | |
| 77 | + | if rest == "downloads" { | |
| 78 | + | return Some(ComposerRoute::Downloads { workspace }); | |
| 79 | + | } | |
| 80 | + | if let Some(file) = rest.strip_prefix("p2/") { | |
| 81 | + | let (name, dev) = match file.strip_suffix("~dev.json") { | |
| 82 | + | Some(name) => (name, true), | |
| 83 | + | None => (file.strip_suffix(".json")?, false), | |
| 84 | + | }; | |
| 85 | + | return composer::valid_name(name).then(|| ComposerRoute::Metadata { workspace, name: name.to_owned(), dev }); | |
| 86 | + | } | |
| 87 | + | let file = rest.strip_prefix("dist/")?; | |
| 88 | + | let (name, zip) = file.rsplit_once('/')?; | |
| 89 | + | let commit = zip.strip_suffix(".zip")?; | |
| 90 | + | (composer::valid_name(name) && commit.len() == 40 && commit.bytes().all(|b| b.is_ascii_hexdigit())).then(|| ComposerRoute::Dist { | |
| 91 | + | workspace, | |
| 92 | + | name: name.to_owned(), | |
| 93 | + | commit: commit.to_ascii_lowercase(), | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// What a version keeps of its ref, to make its entry from on each read. | |
| 98 | + | fn stored_metadata(composer_json: &Value, version: &Version, git_ref: &str, default_branch: bool) -> Value { | |
| 99 | + | json!({ | |
| 100 | + | "composer": composer_json, | |
| 101 | + | "version_normalized": version.normalized, | |
| 102 | + | "ref": git_ref, | |
| 103 | + | "default_branch": default_branch, | |
| 104 | + | }) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// The versions a repository's refs make: `(version, commit, ref, default)`. | |
| 108 | + | fn wanted_versions(refs: &[g1t_contracts::repos::GitRefEntry], default_branch: &str) -> Vec<(Version, String, String, bool)> { | |
| 109 | + | let mut branches = Vec::new(); | |
| 110 | + | let mut tags = Vec::new(); | |
| 111 | + | for entry in refs { | |
| 112 | + | if let Some(branch) = entry.name.strip_prefix("refs/heads/") { | |
| 113 | + | branches.push((composer::branch_version(branch), entry.commit.clone(), entry.name.clone(), branch == default_branch)); | |
| 114 | + | } else if let Some(tag) = entry.name.strip_prefix("refs/tags/") | |
| 115 | + | && let Some(version) = composer::tag_version(tag) | |
| 116 | + | { | |
| 117 | + | tags.push((version, entry.commit.clone(), entry.name.clone(), false)); | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | // The default branch first, then the newest tags. | |
| 121 | + | branches.sort_by_key(|(_, _, _, default)| !*default); | |
| 122 | + | branches.truncate(MAX_BRANCHES); | |
| 123 | + | tags.sort_by(|a, b| b.0.normalized.cmp(&a.0.normalized)); | |
| 124 | + | tags.truncate(MAX_TAGS); | |
| 125 | + | let mut seen = HashSet::new(); | |
| 126 | + | branches.into_iter().chain(tags).filter(|(v, ..)| seen.insert(v.version.clone())).collect() | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | impl Packages { | |
| 130 | + | pub async fn composer(&self, request: Request, ctx: &Context) -> Result<Response> { | |
| 131 | + | let url = request.url()?; | |
| 132 | + | let Some(route) = route(url.path()) else { | |
| 133 | + | return error(404, "There is nothing at this address."); | |
| 134 | + | }; | |
| 135 | + | match self.composer_route(request, &url, route, ctx).await { | |
| 136 | + | Ok(response) => Ok(response), | |
| 137 | + | Err(problem) => { | |
| 138 | + | worker::console_error!("packages: composer {}: {problem}", url.path()); | |
| 139 | + | error(500, "Something went wrong on our side. Try again in a moment.") | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | async fn composer_route(&self, mut request: Request, url: &Url, route: ComposerRoute, ctx: &Context) -> Result<Response> { | |
| 145 | + | let credentials = self.credentials(&request).await?; | |
| 146 | + | if matches!(request.method(), Method::Get | Method::Head) | |
| 147 | + | && let Some(refused) = self.limited(&request, &credentials, "http-basic credentials").await? | |
| 148 | + | { | |
| 149 | + | return Ok(refused); | |
| 150 | + | } | |
| 151 | + | let viewer = match credentials { | |
| 152 | + | Credentials::Viewer(viewer) => viewer, | |
| 153 | + | Credentials::None => None, | |
| 154 | + | Credentials::Token(_) | Credentials::Bad => { | |
| 155 | + | return error(401, "The username or token is not right. Use a g1t access token: composer config --auth http-basic.g1t.sh <you> <token>"); | |
| 156 | + | } | |
| 157 | + | }; | |
| 158 | + | match route { | |
| 159 | + | ComposerRoute::Root { workspace } => self.composer_root(&workspace, viewer.as_ref()).await, | |
| 160 | + | ComposerRoute::Metadata { workspace, name, dev } => self.composer_metadata(url, &workspace, &name, dev, viewer.as_ref()).await, | |
| 161 | + | ComposerRoute::Dist { workspace, name, commit } => self.composer_dist(&workspace, &name, &commit, viewer.as_ref(), ctx).await, | |
| 162 | + | ComposerRoute::Downloads { workspace } => { | |
| 163 | + | let body: Value = request.json().await.unwrap_or_default(); | |
| 164 | + | let names: HashSet<&str> = body["downloads"] | |
| 165 | + | .as_array() | |
| 166 | + | .map(|list| list.iter().filter_map(|d| d["name"].as_str()).collect()) | |
| 167 | + | .unwrap_or_default(); | |
| 168 | + | for name in names.into_iter().take(50) { | |
| 169 | + | if let Some(package) = self.composer_package(&workspace, name).await? { | |
| 170 | + | self.count_download(&package.id, ctx); | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | Ok(Response::empty()?.with_status(204)) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | async fn composer_package(&self, workspace: &str, name: &str) -> Result<Option<PackageRow>> { | |
| 179 | + | Ok(self.db.package(workspace, COMPOSER, name).await?.filter(|p| !p.hidden())) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// The answer when `viewer` may not pull `package`, if they may not. | |
| 183 | + | fn composer_check(&self, viewer: Option<&User>, package: &PackageRow) -> Option<Result<Response>> { | |
| 184 | + | let target = TargetOf::package(package); | |
| 185 | + | if access::decide(viewer, &target.view(), Action::Pull).allowed { | |
| 186 | + | return None; | |
| 187 | + | } | |
| 188 | + | Some(if viewer.is_none() { | |
| 189 | + | error(401, "Sign in to install this package: composer config --auth http-basic.g1t.sh <you> <g1t token>") | |
| 190 | + | } else { | |
| 191 | + | error(404, "Not found: no such package, or you cannot see it.") | |
| 192 | + | }) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | async fn composer_root(&self, workspace: &str, viewer: Option<&User>) -> Result<Response> { | |
| 196 | + | let rows = self.db.list(workspace, Some(COMPOSER), None, None, 1000).await?; | |
| 197 | + | let available: Vec<String> = rows | |
| 198 | + | .iter() | |
| 199 | + | .filter(|row| access::decide(viewer, &TargetOf::package(&row.package).view(), Action::Pull).allowed) | |
| 200 | + | .map(|row| row.package.name.clone()) | |
| 201 | + | .collect(); | |
| 202 | + | Response::from_json(&composer::root(workspace, &available)) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | async fn composer_metadata(&self, url: &Url, workspace: &str, name: &str, dev: bool, viewer: Option<&User>) -> Result<Response> { | |
| 206 | + | let Some(package) = self.composer_package(workspace, name).await? else { | |
| 207 | + | return error(404, format!("There is no package {name} in {workspace}.")); | |
| 208 | + | }; | |
| 209 | + | if let Some(refusal) = self.composer_check(viewer, &package) { | |
| 210 | + | return refusal; | |
| 211 | + | } | |
| 212 | + | let base = origin(url); | |
| 213 | + | let repo = package.repo_name.clone().unwrap_or_default(); | |
| 214 | + | let git_url = format!("{base}/{}/{repo}.git", package.workspace); | |
| 215 | + | let mut entries = Vec::new(); | |
| 216 | + | for row in self.db.versions(&package.id, 1000).await? { | |
| 217 | + | let meta = row.meta(); | |
| 218 | + | let version = Version { | |
| 219 | + | version: row.version.clone(), | |
| 220 | + | normalized: meta["version_normalized"].as_str().unwrap_or(&row.version).to_owned(), | |
| 221 | + | }; | |
| 222 | + | if version.is_dev() != dev { | |
| 223 | + | continue; | |
| 224 | + | } | |
| 225 | + | let dist_url = format!("{base}/-/composer/{}/dist/{name}/{}.zip", package.workspace, row.digest); | |
| 226 | + | let origin = Origin { | |
| 227 | + | git_url: &git_url, | |
| 228 | + | dist_url: &dist_url, | |
| 229 | + | commit: &row.digest, | |
| 230 | + | default_branch: meta["default_branch"].as_bool().unwrap_or(false), | |
| 231 | + | }; | |
| 232 | + | entries.push(composer::version_entry(&meta["composer"], name, &version, &origin)); | |
| 233 | + | } | |
| 234 | + | let mut response = Response::from_json(&composer::p2(name, &entries))?; | |
| 235 | + | response.headers_mut().set("last-modified", &package.updated_at)?; | |
| 236 | + | Ok(response) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | async fn composer_dist(&self, workspace: &str, name: &str, commit: &str, viewer: Option<&User>, ctx: &Context) -> Result<Response> { | |
| 240 | + | let Some(package) = self.composer_package(workspace, name).await? else { | |
| 241 | + | return error(404, format!("There is no package {name} in {workspace}.")); | |
| 242 | + | }; | |
| 243 | + | if let Some(refusal) = self.composer_check(viewer, &package) { | |
| 244 | + | return refusal; | |
| 245 | + | } | |
| 246 | + | // Only the commits of its versions: a zip is never made of any | |
| 247 | + | // other commit of the repository. | |
| 248 | + | if self.db.version_by_digest(&package.id, commit).await?.is_none() { | |
| 249 | + | return error(404, format!("{commit} is not a version of {name}.")); | |
| 250 | + | } | |
| 251 | + | let blob = match self.db.dist_for_commit(&package.id, commit).await? { | |
| 252 | + | Some(blob) => blob, | |
| 253 | + | None => match self.build_dist(&package, commit).await? { | |
| 254 | + | Ok(blob) => blob, | |
| 255 | + | Err(refusal) => return refusal, | |
| 256 | + | }, | |
| 257 | + | }; | |
| 258 | + | let Some(got) = self.store.get(&blob.object_key, None).await? else { | |
| 259 | + | return error(404, "The archive is missing. Try again."); | |
| 260 | + | }; | |
| 261 | + | self.count_download(&package.id, ctx); | |
| 262 | + | let headers = Headers::new(); | |
| 263 | + | headers.set("content-type", "application/zip")?; | |
| 264 | + | headers.set("content-length", &blob.size.to_string())?; | |
| 265 | + | headers.set("cache-control", "max-age=31536000")?; | |
| 266 | + | Ok(Response::from_body(got.body)?.with_headers(headers)) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /// Makes the zip of a commit: its files but those `.gitattributes` | |
| 270 | + | /// marks `export-ignore`, as `git archive` would leave them out. | |
| 271 | + | async fn build_dist(&self, package: &PackageRow, commit: &str) -> Result<std::result::Result<crate::db::BlobRow, Result<Response>>> { | |
| 272 | + | let Some(repo_id) = package.repo_id.clone() else { | |
| 273 | + | return Ok(Err(error(404, "This package has no repository."))); | |
| 274 | + | }; | |
| 275 | + | let listed: FileList = g1t_kit::call( | |
| 276 | + | &self.repos, | |
| 277 | + | "list_files", | |
| 278 | + | &ListFilesArgs { repo_id: repo_id.clone(), git_ref: Some(commit.to_owned()), skip_dirs: Vec::new(), limit: MAX_LISTED_FILES }, | |
| 279 | + | ) | |
| 280 | + | .await?; | |
| 281 | + | if listed.truncated { | |
| 282 | + | return Ok(Err(error(507, format!("The commit has more than {MAX_LISTED_FILES} files, too many for an archive. Install from source: composer install --prefer-source")))); | |
| 283 | + | } | |
| 284 | + | let attributes = self.repo_file(&repo_id, commit, ".gitattributes").await?; | |
| 285 | + | let ignores = attributes.map(|text| composer::export_ignores(&String::from_utf8_lossy(&text))).unwrap_or_default(); | |
| 286 | + | let files: Vec<(String, String)> = listed | |
| 287 | + | .files | |
| 288 | + | .into_iter() | |
| 289 | + | .filter_map(|file| Some((file.path, file.hash?))) | |
| 290 | + | .filter(|(path, _)| !composer::ignored(&ignores, path)) | |
| 291 | + | .collect(); | |
| 292 | + | let mut bytes_of: HashMap<String, Vec<u8>> = HashMap::new(); | |
| 293 | + | let unique: Vec<String> = files.iter().map(|(_, hash)| hash.clone()).collect::<HashSet<_>>().into_iter().collect(); | |
| 294 | + | let mut total = 0u64; | |
| 295 | + | for chunk in unique.chunks(MAX_READ_BLOBS) { | |
| 296 | + | let read: Vec<RawBlob> = g1t_kit::call( | |
| 297 | + | &self.repos, | |
| 298 | + | "raw_blobs", | |
| 299 | + | &RawBlobsArgs { repo_id: repo_id.clone(), hashes: chunk.to_vec(), max_bytes: MAX_ARCHIVED_FILE }, | |
| 300 | + | ) | |
| 301 | + | .await?; | |
| 302 | + | for blob in read { | |
| 303 | + | total += blob.size; | |
| 304 | + | if total > MAX_ARCHIVE_BYTES || (blob.data.is_none() && blob.size > 0) { | |
| 305 | + | return Ok(Err(error( | |
| 306 | + | 507, | |
| 307 | + | format!("The commit is too large for an archive (over {} MB). Install from source: composer install --prefer-source", MAX_ARCHIVE_BYTES / 1_048_576), | |
| 308 | + | ))); | |
| 309 | + | } | |
| 310 | + | let data = blob.data.as_deref().map(|d| STANDARD.decode(d).unwrap_or_default()).unwrap_or_default(); | |
| 311 | + | bytes_of.insert(blob.hash, data); | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | let entries: Vec<(String, Vec<u8>)> = files | |
| 315 | + | .into_iter() | |
| 316 | + | .map(|(path, hash)| { | |
| 317 | + | let data = bytes_of.get(&hash).cloned().unwrap_or_default(); | |
| 318 | + | (path, data) | |
| 319 | + | }) | |
| 320 | + | .collect(); | |
| 321 | + | let zip = composer::zip(&entries); | |
| 322 | + | let digest = Digest::of(&zip); | |
| 323 | + | let size = zip.len() as u64; | |
| 324 | + | let now = now_ms(); | |
| 325 | + | if self.db.blob(&digest).await?.is_none() { | |
| 326 | + | self.store.put(&digest.object_key(), zip).await?; | |
| 327 | + | } | |
| 328 | + | self.db.keep_blob(&package.id, &digest, size, Some("application/zip"), &digest.object_key(), now).await?; | |
| 329 | + | self.db.add_dist(&package.id, commit, &digest, size).await?; | |
| 330 | + | self.db.measure(&package.workspace).await?; | |
| 331 | + | Ok(Ok(crate::db::BlobRow { digest: digest.to_string(), size, media_type: Some("application/zip".into()), object_key: digest.object_key() })) | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | /// A file of a repository at a commit, if it is there and not large. | |
| 335 | + | async fn repo_file(&self, repo_id: &str, git_ref: &str, path: &str) -> Result<Option<Vec<u8>>> { | |
| 336 | + | let file: Option<RawFile> = g1t_kit::call( | |
| 337 | + | &self.repos, | |
| 338 | + | "raw_file", | |
| 339 | + | &RawFileArgs { repo_id: repo_id.to_owned(), git_ref: git_ref.to_owned(), path: path.to_owned(), max_bytes: MAX_FILE_BYTES }, | |
| 340 | + | ) | |
| 341 | + | .await?; | |
| 342 | + | Ok(file.and_then(|file| STANDARD.decode(file.data).ok())) | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | /// Reads a repository's Composer package again from its refs: makes it | |
| 346 | + | /// when its default branch gained a `composer.json`, records new and | |
| 347 | + | /// moved versions, lets go of deleted ones, and deletes the package | |
| 348 | + | /// when the repository stopped being one. Says whether it is one. | |
| 349 | + | pub(crate) async fn sync_composer(&self, repo_id: &str) -> Result<bool> { | |
| 350 | + | let found: Option<RepoRefs> = g1t_kit::call(&self.repos, "refs", &RefsArgs { repo_id: repo_id.to_owned() }).await?; | |
| 351 | + | let existing = self.db.package_for_repo(repo_id, COMPOSER).await?; | |
| 352 | + | let Some(RepoRefs { repo, refs }) = found else { | |
| 353 | + | if let Some(package) = existing { | |
| 354 | + | self.drop_composer(&package).await?; | |
| 355 | + | } | |
| 356 | + | return Ok(false); | |
| 357 | + | }; | |
| 358 | + | let workspace = repo.namespace.to_lowercase(); | |
| 359 | + | if self.db.workspace_hidden(&workspace).await? { | |
| 360 | + | return Ok(false); | |
| 361 | + | } | |
| 362 | + | let default = refs.iter().find(|r| r.name == format!("refs/heads/{}", repo.default_branch)).map(|r| r.commit.clone()); | |
| 363 | + | let manifest = match &default { | |
| 364 | + | Some(commit) => self.composer_json(repo_id, commit).await?, | |
| 365 | + | None => None, | |
| 366 | + | }; | |
| 367 | + | let Some((name, root_manifest)) = manifest else { | |
| 368 | + | if let Some(package) = existing { | |
| 369 | + | self.drop_composer(&package).await?; | |
| 370 | + | } | |
| 371 | + | return Ok(false); | |
| 372 | + | }; | |
| 373 | + | // A repository moved to another workspace takes its package along. | |
| 374 | + | let existing = match existing { | |
| 375 | + | Some(package) if package.workspace != workspace => { | |
| 376 | + | self.drop_composer(&package).await?; | |
| 377 | + | None | |
| 378 | + | } | |
| 379 | + | other => other, | |
| 380 | + | }; | |
| 381 | + | let now = now_ms(); | |
| 382 | + | let package = match existing { | |
| 383 | + | Some(package) if package.name == name => package, | |
| 384 | + | Some(package) => { | |
| 385 | + | if self.db.package(&workspace, COMPOSER, &name).await?.is_some() { | |
| 386 | + | worker::console_error!("packages: {workspace}/{} names {name}, which another repository has", repo.name); | |
| 387 | + | package | |
| 388 | + | } else { | |
| 389 | + | self.db.rename_package(&package.id, &name, now).await?; | |
| 390 | + | PackageRow { name: name.clone(), ..package } | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | None => { | |
| 394 | + | if let Some(other) = self.db.package(&workspace, COMPOSER, &name).await? | |
| 395 | + | && other.repo_id.as_deref() != Some(repo_id) | |
| 396 | + | { | |
| 397 | + | worker::console_error!("packages: {workspace}/{} names {name}, which another repository has", repo.name); | |
| 398 | + | return Ok(false); | |
| 399 | + | } | |
| 400 | + | self.db | |
| 401 | + | .create_package(&new_id("pkg", now), &workspace, COMPOSER, &name, Some((&repo.id, &repo.name, repo.is_private)), "g1t", now) | |
| 402 | + | .await? | |
| 403 | + | } | |
| 404 | + | }; | |
| 405 | + | ||
| 406 | + | let caller = Caller { actor: None }; | |
| 407 | + | let wanted = wanted_versions(&refs, &repo.default_branch); | |
| 408 | + | let stored = self.db.versions(&package.id, 1000).await?; | |
| 409 | + | let mut manifests: HashMap<String, Option<Value>> = HashMap::new(); | |
| 410 | + | if let Some(commit) = &default { | |
| 411 | + | manifests.insert(commit.clone(), Some(root_manifest.clone())); | |
| 412 | + | } | |
| 413 | + | let mut changed = false; | |
| 414 | + | for (version, commit, git_ref, is_default) in &wanted { | |
| 415 | + | let current = stored.iter().find(|row| row.version == version.version); | |
| 416 | + | if let Some(row) = current | |
| 417 | + | && row.digest == *commit | |
| 418 | + | && row.meta()["default_branch"].as_bool().unwrap_or(false) == *is_default | |
| 419 | + | { | |
| 420 | + | continue; | |
| 421 | + | } | |
| 422 | + | if !manifests.contains_key(commit) { | |
| 423 | + | let read = self.composer_json(repo_id, commit).await?.map(|(_, json)| json); | |
| 424 | + | manifests.insert(commit.clone(), read); | |
| 425 | + | } | |
| 426 | + | // A ref without a composer.json of its own is not a version. | |
| 427 | + | let Some(Some(json)) = manifests.get(commit) else { continue }; | |
| 428 | + | self.db | |
| 429 | + | .replace_version( | |
| 430 | + | &NewVersion { | |
| 431 | + | id: new_id("ver", now), | |
| 432 | + | package_id: package.id.clone(), | |
| 433 | + | version: version.version.clone(), | |
| 434 | + | digest: commit.clone(), | |
| 435 | + | size: 0, | |
| 436 | + | metadata: stored_metadata(json, version, git_ref, *is_default).to_string(), | |
| 437 | + | subject: None, | |
| 438 | + | published_by: None, | |
| 439 | + | files: Vec::new(), | |
| 440 | + | }, | |
| 441 | + | now, | |
| 442 | + | ) | |
| 443 | + | .await?; | |
| 444 | + | changed = true; | |
| 445 | + | if current.is_none() { | |
| 446 | + | let event = PackageEvent { version: Some(version.version.clone()), digest: Some(commit.clone()), ..self.event_of(&package) }; | |
| 447 | + | self.announce("package.published", &package, event, &caller).await; | |
| 448 | + | } | |
| 449 | + | } | |
| 450 | + | let kept: HashSet<&str> = wanted.iter().map(|(v, ..)| v.version.as_str()).collect(); | |
| 451 | + | for row in stored.iter().filter(|row| !kept.contains(row.version.as_str())) { | |
| 452 | + | self.db.delete_version(&row.id).await?; | |
| 453 | + | let event = PackageEvent { version: Some(row.version.clone()), digest: Some(row.digest.clone()), ..self.event_of(&package) }; | |
| 454 | + | self.announce("package.version_deleted", &package, event, &caller).await; | |
| 455 | + | changed = true; | |
| 456 | + | } | |
| 457 | + | if let Some(commit) = &default { | |
| 458 | + | self.composer_readme(&package, repo_id, commit, &root_manifest, now).await?; | |
| 459 | + | } | |
| 460 | + | if changed { | |
| 461 | + | self.db.measure(&workspace).await?; | |
| 462 | + | } | |
| 463 | + | Ok(true) | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | /// The package's README and description, from the default branch. | |
| 467 | + | async fn composer_readme(&self, package: &PackageRow, repo_id: &str, commit: &str, manifest: &Value, now: u64) -> Result<()> { | |
| 468 | + | let mut readme = None; | |
| 469 | + | for name in README_NAMES { | |
| 470 | + | if let Some(bytes) = self.repo_file(repo_id, commit, name).await? { | |
| 471 | + | readme = Some(bytes); | |
| 472 | + | break; | |
| 473 | + | } | |
| 474 | + | } | |
| 475 | + | let digest = match readme.filter(|b| !b.is_empty()) { | |
| 476 | + | Some(bytes) => { | |
| 477 | + | let digest = Digest::of(&bytes); | |
| 478 | + | if self.db.blob(&digest).await?.is_none() { | |
| 479 | + | self.store.put(&digest.object_key(), bytes.clone()).await?; | |
| 480 | + | } | |
| 481 | + | self.db.keep_blob(&package.id, &digest, bytes.len() as u64, Some("text/markdown"), &digest.object_key(), now).await?; | |
| 482 | + | Some(digest.to_string()) | |
| 483 | + | } | |
| 484 | + | None => None, | |
| 485 | + | }; | |
| 486 | + | self.db.set_readme(&package.id, digest.as_deref(), manifest["description"].as_str(), now).await | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | /// A commit's `composer.json`, when it has one naming a valid package. | |
| 490 | + | async fn composer_json(&self, repo_id: &str, commit: &str) -> Result<Option<(String, Value)>> { | |
| 491 | + | let Some(bytes) = self.repo_file(repo_id, commit, "composer.json").await? else { | |
| 492 | + | return Ok(None); | |
| 493 | + | }; | |
| 494 | + | let Ok(json) = serde_json::from_slice::<Value>(&bytes) else { | |
| 495 | + | return Ok(None); | |
| 496 | + | }; | |
| 497 | + | let Some(name) = json["name"].as_str().map(str::to_lowercase).filter(|n| composer::valid_name(n)) else { | |
| 498 | + | return Ok(None); | |
| 499 | + | }; | |
| 500 | + | Ok(Some((name, json))) | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | async fn drop_composer(&self, package: &PackageRow) -> Result<()> { | |
| 504 | + | self.db.delete_package(&package.id).await?; | |
| 505 | + | self.db.measure(&package.workspace).await?; | |
| 506 | + | self.announce("package.deleted", package, self.event_of(package), &Caller { actor: None }).await; | |
| 507 | + | Ok(()) | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | /// Deletes the Composer package built from a deleted repository. | |
| 511 | + | pub(crate) async fn composer_repo_gone(&self, repo_id: &str) -> Result<()> { | |
| 512 | + | if let Some(package) = self.db.package_for_repo(repo_id, COMPOSER).await? { | |
| 513 | + | self.drop_composer(&package).await?; | |
| 514 | + | } | |
| 515 | + | Ok(()) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | /// Reads a page of repositories the backfill has not yet, until it has | |
| 519 | + | /// read them all once. Says how many were packages. | |
| 520 | + | pub(crate) async fn composer_backfill(&self) -> Result<u32> { | |
| 521 | + | let (after, finished) = self.db.backfill().await?; | |
| 522 | + | if finished { | |
| 523 | + | return Ok(0); | |
| 524 | + | } | |
| 525 | + | let page: IdPage = g1t_kit::call(&self.repos, "all_ids", &AllIdsArgs { after: after.clone(), limit: BACKFILL_PAGE }).await?; | |
| 526 | + | let mut found = 0; | |
| 527 | + | for id in &page.ids { | |
| 528 | + | match self.sync_composer(id).await { | |
| 529 | + | Ok(true) => found += 1, | |
| 530 | + | Ok(false) => {} | |
| 531 | + | Err(error) => worker::console_error!("packages: composer backfill of {id}: {error}"), | |
| 532 | + | } | |
| 533 | + | } | |
| 534 | + | let last = page.ids.last().cloned().or(after); | |
| 535 | + | self.db.set_backfill(last.as_deref(), page.next.is_none(), now_ms()).await?; | |
| 536 | + | Ok(found) | |
| 537 | + | } | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | #[cfg(test)] | |
| 541 | + | mod tests { | |
| 542 | + | use super::*; | |
| 543 | + | use g1t_contracts::repos::GitRefEntry; | |
| 544 | + | ||
| 545 | + | #[test] | |
| 546 | + | fn every_endpoint_is_routed() { | |
| 547 | + | let commit = "a".repeat(40); | |
| 548 | + | assert_eq!(route("/-/composer/acme/packages.json"), Some(ComposerRoute::Root { workspace: "acme".into() })); | |
| 549 | + | assert_eq!(route("/-/composer/acme/"), Some(ComposerRoute::Root { workspace: "acme".into() })); | |
| 550 | + | assert_eq!( | |
| 551 | + | route("/-/composer/acme/p2/acme/lib.json"), | |
| 552 | + | Some(ComposerRoute::Metadata { workspace: "acme".into(), name: "acme/lib".into(), dev: false }) | |
| 553 | + | ); | |
| 554 | + | assert_eq!( | |
| 555 | + | route("/-/composer/acme/p2/acme/lib~dev.json"), | |
| 556 | + | Some(ComposerRoute::Metadata { workspace: "acme".into(), name: "acme/lib".into(), dev: true }) | |
| 557 | + | ); | |
| 558 | + | assert_eq!( | |
| 559 | + | route(&format!("/-/composer/acme/dist/acme/lib/{commit}.zip")), | |
| 560 | + | Some(ComposerRoute::Dist { workspace: "acme".into(), name: "acme/lib".into(), commit: commit.clone() }) | |
| 561 | + | ); | |
| 562 | + | assert_eq!(route("/-/composer/acme/downloads"), Some(ComposerRoute::Downloads { workspace: "acme".into() })); | |
| 563 | + | assert_eq!(route("/-/composer/acme/p2/Acme/lib.json"), None); | |
| 564 | + | assert_eq!(route("/-/composer/acme/dist/acme/lib/short.zip"), None); | |
| 565 | + | assert_eq!(route("/-/composer/acme"), None); | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | #[test] | |
| 569 | + | fn a_repositorys_refs_make_its_versions() { | |
| 570 | + | let entry = |name: &str, commit: &str| GitRefEntry { name: name.into(), commit: commit.into() }; | |
| 571 | + | let refs = [ | |
| 572 | + | entry("refs/heads/feature", "f"), | |
| 573 | + | entry("refs/heads/main", "m"), | |
| 574 | + | entry("refs/tags/v1.0.0", "a"), | |
| 575 | + | entry("refs/tags/v1.1.0", "b"), | |
| 576 | + | entry("refs/tags/nightly", "n"), | |
| 577 | + | ]; | |
| 578 | + | let wanted = wanted_versions(&refs, "main"); | |
| 579 | + | let names: Vec<(&str, &str, bool)> = wanted.iter().map(|(v, c, _, d)| (v.version.as_str(), c.as_str(), *d)).collect(); | |
| 580 | + | assert_eq!( | |
| 581 | + | names, | |
| 582 | + | [("dev-main", "m", true), ("dev-feature", "f", false), ("v1.1.0", "b", false), ("v1.0.0", "a", false)], | |
| 583 | + | "the default branch first, newest tags next, tags that are not versions left out" | |
| 584 | + | ); | |
| 585 | + | } | |
| 586 | + | } |
| 584 | 584 | .results() | |
| 585 | 585 | } | |
| 586 | 586 | ||
| 587 | + | /// The package of an ecosystem built from a repository (Composer's). | |
| 588 | + | pub async fn package_for_repo(&self, repo_id: &str, ecosystem: &str) -> Result<Option<PackageRow>> { | |
| 589 | + | self.prepare( | |
| 590 | + | &format!("SELECT {PACKAGE_COLUMNS} FROM packages WHERE repo_id = ? AND ecosystem = ? LIMIT 1"), | |
| 591 | + | &[text(repo_id), text(ecosystem)], | |
| 592 | + | )? | |
| 593 | + | .first(None) | |
| 594 | + | .await | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | pub async fn rename_package(&self, package_id: &str, name: &str, now_ms: u64) -> Result<()> { | |
| 598 | + | self.prepare( | |
| 599 | + | "UPDATE packages SET name = ?, updated_at = ? WHERE id = ?", | |
| 600 | + | &[text(name), text(&rfc3339(now_ms)), text(package_id)], | |
| 601 | + | )? | |
| 602 | + | .run() | |
| 603 | + | .await?; | |
| 604 | + | Ok(()) | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | /// Records a version, in place of one of the same version string | |
| 608 | + | /// (a tag or branch that moved): its files and tags go with the old one. | |
| 609 | + | pub async fn replace_version(&self, version: &NewVersion, now_ms: u64) -> Result<()> { | |
| 610 | + | let now = rfc3339(now_ms); | |
| 611 | + | let old = [text(&version.package_id), text(&version.version)]; | |
| 612 | + | let old_ids = "SELECT id FROM versions WHERE package_id = ? AND version = ?"; | |
| 613 | + | let mut batch = vec![ | |
| 614 | + | self.prepare(&format!("DELETE FROM tags WHERE version_id IN ({old_ids})"), &old)?, | |
| 615 | + | self.prepare(&format!("DELETE FROM version_files WHERE version_id IN ({old_ids})"), &old)?, | |
| 616 | + | self.prepare("DELETE FROM versions WHERE package_id = ? AND version = ?", &old)?, | |
| 617 | + | self.prepare( | |
| 618 | + | "INSERT INTO versions (id, package_id, version, digest, size, metadata, subject, published_by, published_at) | |
| 619 | + | VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)", | |
| 620 | + | &[ | |
| 621 | + | text(&version.id), | |
| 622 | + | text(&version.package_id), | |
| 623 | + | text(&version.version), | |
| 624 | + | text(&version.digest), | |
| 625 | + | num(version.size), | |
| 626 | + | text(&version.metadata), | |
| 627 | + | opt(version.published_by.as_deref()), | |
| 628 | + | text(&now), | |
| 629 | + | ], | |
| 630 | + | )?, | |
| 631 | + | ]; | |
| 632 | + | for file in &version.files { | |
| 633 | + | batch.push(self.prepare( | |
| 634 | + | "INSERT OR IGNORE INTO version_files (version_id, name, digest, size, media_type) VALUES (?, ?, ?, ?, ?)", | |
| 635 | + | &[text(&version.id), text(&file.name), text(&file.digest), num(file.size), opt(file.media_type.as_deref())], | |
| 636 | + | )?); | |
| 637 | + | } | |
| 638 | + | batch.push(self.prepare("UPDATE packages SET updated_at = ? WHERE id = ?", &[text(&now), text(&version.package_id)])?); | |
| 639 | + | self.db.batch(batch).await?; | |
| 640 | + | Ok(()) | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | /// The zip already made of a commit of the package, if one was. | |
| 644 | + | pub async fn dist_for_commit(&self, package_id: &str, commit: &str) -> Result<Option<BlobRow>> { | |
| 645 | + | self.prepare( | |
| 646 | + | "SELECT b.digest, b.size, b.media_type, b.object_key FROM versions v | |
| 647 | + | JOIN version_files vf ON vf.version_id = v.id AND vf.name = 'dist' | |
| 648 | + | JOIN blobs b ON b.digest = vf.digest | |
| 649 | + | WHERE v.package_id = ? AND v.digest = ? LIMIT 1", | |
| 650 | + | &[text(package_id), text(commit)], | |
| 651 | + | )? | |
| 652 | + | .first(None) | |
| 653 | + | .await | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | /// Records the zip of a commit as a file of every version at it. | |
| 657 | + | pub async fn add_dist(&self, package_id: &str, commit: &str, digest: &Digest, size: u64) -> Result<()> { | |
| 658 | + | let at = [text(package_id), text(commit)]; | |
| 659 | + | self.db | |
| 660 | + | .batch(vec![ | |
| 661 | + | self.prepare( | |
| 662 | + | "INSERT OR IGNORE INTO version_files (version_id, name, digest, size, media_type) | |
| 663 | + | SELECT id, 'dist', ?, ?, 'application/zip' FROM versions WHERE package_id = ? AND digest = ?", | |
| 664 | + | &[text(digest.as_str()), num(size), text(package_id), text(commit)], | |
| 665 | + | )?, | |
| 666 | + | self.prepare("UPDATE versions SET size = ? WHERE package_id = ? AND digest = ?", &[num(size), at[0].clone(), at[1].clone()])?, | |
| 667 | + | ]) | |
| 668 | + | .await?; | |
| 669 | + | Ok(()) | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | /// Where the Composer backfill is: the last repository done, and | |
| 673 | + | /// whether it went through them all. | |
| 674 | + | pub async fn backfill(&self) -> Result<(Option<String>, bool)> { | |
| 675 | + | #[derive(Deserialize)] | |
| 676 | + | struct Row { | |
| 677 | + | after: Option<String>, | |
| 678 | + | finished_at: Option<String>, | |
| 679 | + | } | |
| 680 | + | let row: Option<Row> = self | |
| 681 | + | .prepare("SELECT after, finished_at FROM composer_backfill WHERE key = 'repos'", &[])? | |
| 682 | + | .first(None) | |
| 683 | + | .await?; | |
| 684 | + | Ok(row.map_or((None, false), |row| (row.after, row.finished_at.is_some()))) | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | pub async fn set_backfill(&self, after: Option<&str>, finished: bool, now_ms: u64) -> Result<()> { | |
| 688 | + | self.prepare( | |
| 689 | + | "INSERT INTO composer_backfill (key, after, finished_at) VALUES ('repos', ?, ?) | |
| 690 | + | ON CONFLICT (key) DO UPDATE SET after = excluded.after, finished_at = excluded.finished_at", | |
| 691 | + | &[opt(after), if finished { text(&rfc3339(now_ms)) } else { JsValue::NULL }], | |
| 692 | + | )? | |
| 693 | + | .run() | |
| 694 | + | .await?; | |
| 695 | + | Ok(()) | |
| 696 | + | } | |
| 697 | + | ||
| 587 | 698 | /// Points `tag` at a version, made or moved. | |
| 588 | 699 | pub async fn set_tag(&self, package_id: &str, tag: &str, version_id: &str, now_ms: u64) -> Result<()> { | |
| 589 | 700 | self.prepare( |
| 8 | 8 | //! `BlobStore` port (store/), metadata in D1 (db.rs). | |
| 9 | 9 | ||
| 10 | 10 | mod access; | |
| 11 | + | mod composer; | |
| 12 | + | mod composer_http; | |
| 11 | 13 | mod db; | |
| 12 | 14 | mod digest; | |
| 13 | 15 | mod limits; | |
| 345 | 347 | name: p.name.clone(), | |
| 346 | 348 | address: match p.ecosystem.as_str() { | |
| 347 | 349 | "npm" => format!("{}/-/npm/@{}/{}", self.host, p.workspace, p.name), | |
| 350 | + | "composer" => format!("{}/-/composer/{}/{}", self.host, p.workspace, p.name), | |
| 348 | 351 | _ => format!("{}/{}/{}", self.host, p.workspace, p.name), | |
| 349 | 352 | }, | |
| 350 | 353 | visibility: Visibility::parse(&p.visibility), | |
| 565 | 568 | /// Follows what happens elsewhere: workspaces renamed and deleted, | |
| 566 | 569 | /// repositories that change visibility, are renamed, move or go. | |
| 567 | 570 | async fn on_event(&self, env: &Env, event: &Event) -> Result<()> { | |
| 571 | + | // Composer packages are read from repositories: again when one is | |
| 572 | + | // pushed to (its default branch may have gained a composer.json), | |
| 573 | + | // restored, renamed or moved; gone when it is deleted. A failure is | |
| 574 | + | // logged, not retried with the batch: the next push reads it again. | |
| 575 | + | let repo_id = event.repo_id.clone().or_else(|| event.data["repoId"].as_str().map(str::to_owned)); | |
| 576 | + | if let Some(repo_id) = repo_id.as_deref() { | |
| 577 | + | let synced = match event.kind.as_str() { | |
| 578 | + | "git.push" => { | |
| 579 | + | let known = self.db.package_for_repo(repo_id, "composer").await?.is_some(); | |
| 580 | + | if known || event.data["defaultBranch"].as_bool() == Some(true) { | |
| 581 | + | Some(self.sync_composer(repo_id).await.map(|_| ())) | |
| 582 | + | } else { | |
| 583 | + | None | |
| 584 | + | } | |
| 585 | + | } | |
| 586 | + | "repo.deleted" => Some(self.composer_repo_gone(repo_id).await), | |
| 587 | + | "repo.restored" | "repo.renamed" | "repo.transferred" => Some(self.sync_composer(repo_id).await.map(|_| ())), | |
| 588 | + | _ => None, | |
| 589 | + | }; | |
| 590 | + | if let Some(Err(error)) = synced { | |
| 591 | + | worker::console_error!("packages: composer {} for {repo_id}: {error}", event.kind); | |
| 592 | + | } | |
| 593 | + | if event.kind == "git.push" || event.kind == "repo.deleted" || event.kind == "repo.restored" { | |
| 594 | + | return Ok(()); | |
| 595 | + | } | |
| 596 | + | } | |
| 568 | 597 | let db = &self.db.db; | |
| 569 | 598 | let protected = g1t_contracts::identity::protected_names(Some(&store::var(env, "PROTECTED_WORKSPACES"))); | |
| 570 | 599 | match workspace_mark(event, &protected, &g1t_contracts::time::rfc3339(now_ms())) { | |
| 637 | 666 | if request.path().starts_with("/-/npm/") || request.path() == "/-/npm" { | |
| 638 | 667 | return packages.npm(request, &ctx).await; | |
| 639 | 668 | } | |
| 669 | + | if request.path().starts_with("/-/composer/") { | |
| 670 | + | return packages.composer(request, &ctx).await; | |
| 671 | + | } | |
| 640 | 672 | return packages.registry(request, &ctx).await; | |
| 641 | 673 | }; | |
| 642 | 674 | let body: serde_json::Value = request.json().await?; | |
| 648 | 680 | "set_package" => reply(&packages.set_package(args(body)?).await?), | |
| 649 | 681 | // For billing: what a workspace's packages hold. | |
| 650 | 682 | "storage_all" => reply(&packages.db.storage_all().await?), | |
| 683 | + | // Read a repository's Composer package again now, as a push would. | |
| 684 | + | "sync_composer" => { | |
| 685 | + | let a: SyncComposerArgs = args(body)?; | |
| 686 | + | reply(&packages.sync_composer(&a.repo_id).await?) | |
| 687 | + | } | |
| 651 | 688 | "storage" => { | |
| 652 | 689 | let a: StorageArgs = args(body)?; | |
| 653 | 690 | let (public_bytes, private_bytes) = packages.db.storage(&a.workspace.to_lowercase()).await?; | |
| 673 | 710 | Ok((uploads, blobs)) => worker::console_log!("packages: let go of {uploads} uploads and {blobs} blobs"), | |
| 674 | 711 | Err(error) => worker::console_error!("packages: the sweep failed: {error}"), | |
| 675 | 712 | } | |
| 713 | + | // Repositories that had a composer.json before the registry did. | |
| 714 | + | match packages.composer_backfill().await { | |
| 715 | + | Ok(0) => {} | |
| 716 | + | Ok(found) => worker::console_log!("packages: found {found} Composer packages"), | |
| 717 | + | Err(error) => worker::console_error!("packages: the Composer backfill failed: {error}"), | |
| 718 | + | } | |
| 676 | 719 | } | |
| 677 | 720 | ||
| 678 | 721 | #[event(queue)] |
| 19 | 19 | futures-util = { version = "0.3", default-features = false, features = ["alloc"] } | |
| 20 | 20 | similar = "2" | |
| 21 | 21 | miniz_oxide = "0.9" | |
| 22 | + | base64 = "0.22" | |
| 22 | 23 | ||
| 23 | 24 | # wasm-opt at -O1: about the same gzipped size as -O in a tenth of the | |
| 24 | 25 | # time (docs/DEPLOYING.md, "Build speed"). |
| 382 | 382 | listing::list(&git, a.base.as_deref(), &a.head, &a.skip_dirs, a.limit).await | |
| 383 | 383 | } | |
| 384 | 384 | ||
| 385 | + | /// Branches and tags with their commits, for g1t's own services. | |
| 386 | + | async fn refs_of(&self, a: RefsArgs) -> Result<Option<RepoRefs>> { | |
| 387 | + | let Some(repo) = self.registry.by_id(&a.repo_id).await?.filter(|repo| repo.fork_of.is_none()) else { | |
| 388 | + | return Ok(None); | |
| 389 | + | }; | |
| 390 | + | let git = self.store.open(&store_key(&repo)).await?; | |
| 391 | + | let access = git.access(Scope::Read).await?; | |
| 392 | + | let refs = refs::heads_and_tags(refs::all(&access).await?) | |
| 393 | + | .into_iter() | |
| 394 | + | .map(|(name, commit)| GitRefEntry { name, commit }) | |
| 395 | + | .collect(); | |
| 396 | + | Ok(Some(RepoRefs { repo, refs })) | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | async fn raw_file(&self, a: RawFileArgs) -> Result<Option<RawFile>> { | |
| 400 | + | use base64::Engine; | |
| 401 | + | let Some(git) = self.stored(&a.repo_id).await? else { | |
| 402 | + | return Ok(None); | |
| 403 | + | }; | |
| 404 | + | Ok(git | |
| 405 | + | .read_file(&a.git_ref, &a.path) | |
| 406 | + | .await? | |
| 407 | + | .filter(|bytes| bytes.len() <= a.max_bytes as usize) | |
| 408 | + | .map(|bytes| RawFile { size: bytes.len() as u64, data: base64::engine::general_purpose::STANDARD.encode(bytes) })) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | async fn raw_blobs(&self, a: RawBlobsArgs) -> Result<Vec<RawBlob>> { | |
| 412 | + | use base64::Engine; | |
| 413 | + | let Some(git) = self.stored(&a.repo_id).await? else { | |
| 414 | + | return Ok(Vec::new()); | |
| 415 | + | }; | |
| 416 | + | let hashes: Vec<&String> = a.hashes.iter().take(MAX_READ_BLOBS).collect(); | |
| 417 | + | let mut out = Vec::with_capacity(hashes.len()); | |
| 418 | + | // A few at a time, as listing::read does: each is a round trip. | |
| 419 | + | for group in hashes.chunks(8) { | |
| 420 | + | let read = futures_util::future::try_join_all(group.iter().map(|hash| git.read_blob(hash))).await?; | |
| 421 | + | for (hash, bytes) in group.iter().zip(read) { | |
| 422 | + | let size = bytes.as_ref().map_or(0, |bytes| bytes.len() as u64); | |
| 423 | + | let data = bytes | |
| 424 | + | .filter(|bytes| bytes.len() <= a.max_bytes as usize) | |
| 425 | + | .map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes)); | |
| 426 | + | out.push(RawBlob { hash: (*hash).clone(), size, data }); | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | Ok(out) | |
| 430 | + | } | |
| 431 | + | ||
| 385 | 432 | async fn read_blobs(&self, a: ReadBlobsArgs) -> Result<Vec<BlobText>> { | |
| 386 | 433 | let Some(git) = self.stored(&a.repo_id).await? else { | |
| 387 | 434 | return Ok(Vec::new()); | |
| 1900 | 1947 | "list_files" => reply(&repos.list_files(args(body)?).await?), | |
| 1901 | 1948 | "changed_files" => reply(&repos.changed_files(args(body)?).await?), | |
| 1902 | 1949 | "read_blobs" => reply(&repos.read_blobs(args(body)?).await?), | |
| 1950 | + | // Services only: what the Composer registry builds packages from. | |
| 1951 | + | "refs" => reply(&repos.refs_of(args(body)?).await?), | |
| 1952 | + | "raw_file" => reply(&repos.raw_file(args(body)?).await?), | |
| 1953 | + | "raw_blobs" => reply(&repos.raw_blobs(args(body)?).await?), | |
| 1903 | 1954 | "visibility" => { | |
| 1904 | 1955 | let a: g1t_contracts::repos::VisibilityArgs = args(body)?; | |
| 1905 | 1956 | reply(&repos.registry.visibility(&a.paths).await?) |
| 35 | 35 | .collect() | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | + | /// Branches and tags from every ref, an annotated tag's commit taken from | |
| 39 | + | /// its peeled line (`refs/tags/v1^{}`) when the advertisement has one. | |
| 40 | + | pub fn heads_and_tags(all: Vec<(String, String)>) -> Vec<(String, String)> { | |
| 41 | + | let mut out: Vec<(String, String)> = Vec::new(); | |
| 42 | + | for (name, hash) in all { | |
| 43 | + | if let Some(tag) = name.strip_suffix("^{}") { | |
| 44 | + | match out.iter_mut().find(|(seen, _)| seen == tag) { | |
| 45 | + | Some(entry) => entry.1 = hash, | |
| 46 | + | None => out.push((tag.to_owned(), hash)), | |
| 47 | + | } | |
| 48 | + | continue; | |
| 49 | + | } | |
| 50 | + | if name.starts_with(HEADS) || name.starts_with("refs/tags/") { | |
| 51 | + | out.push((name, hash)); | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | out | |
| 55 | + | } | |
| 56 | + | ||
| 38 | 57 | pub async fn branches(access: &GitAccess) -> Result<Vec<Branch>> { | |
| 39 | 58 | Ok(parse_advertisement(&advertisement(access).await?)) | |
| 40 | 59 | } | |
| 100 | 119 | } | |
| 101 | 120 | ||
| 102 | 121 | #[test] | |
| 122 | + | fn annotated_tags_are_peeled_and_other_refs_left_out() { | |
| 123 | + | let all = vec![ | |
| 124 | + | ("refs/heads/main".to_owned(), "a".to_owned()), | |
| 125 | + | ("refs/pull/1/head".to_owned(), "b".to_owned()), | |
| 126 | + | ("refs/tags/v1".to_owned(), "tagobject".to_owned()), | |
| 127 | + | ("refs/tags/v1^{}".to_owned(), "c".to_owned()), | |
| 128 | + | ("refs/tags/v2".to_owned(), "d".to_owned()), | |
| 129 | + | ]; | |
| 130 | + | assert_eq!( | |
| 131 | + | super::heads_and_tags(all), | |
| 132 | + | vec![ | |
| 133 | + | ("refs/heads/main".to_owned(), "a".to_owned()), | |
| 134 | + | ("refs/tags/v1".to_owned(), "c".to_owned()), | |
| 135 | + | ("refs/tags/v2".to_owned(), "d".to_owned()), | |
| 136 | + | ] | |
| 137 | + | ); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[test] | |
| 103 | 141 | fn an_empty_repository_has_no_branches() { | |
| 104 | 142 | let advertisement = [pkt("# service=git-upload-pack\n"), b"00000000".to_vec()].concat(); | |
| 105 | 143 | assert!(parse_advertisement(&advertisement).is_empty()); |