Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ paths-ignore:
# instead, as a release build compiles it.
- "**/tests"
- crates/e2e
- crates/plugin/src/testing.rs
- crates/plugin-api/src/testing.rs
- crates/format-api/src/testing.rs
# The web UI: unit tests next to the code, the Playwright suite with its
# mock upstream, and the test runners' configuration.
- "**/*.test.ts"
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ its pull request's description says why.
A package format is a crate in `crates/formats/<id>/`, the directory named
after its format id, linked into the server like `crates/formats/nuget`
(Forge loads no WebAssembly plugins). Never add a format as a crate elsewhere
under `crates/` (no `crates/format-<id>`) or as a module of the server: it
implements `FormatPlugin` with the layout
under `crates/` (no `crates/format-<id>`: `crates/format-api` is the contract
the formats implement, not a format) or as a module of the server: it
implements `FormatSpec` with the layout
[crates/formats/README.md](crates/formats/README.md) describes. Docker / OCI
(`crates/formats/docker`) is one too, an images format: it speaks the
registry protocol, and the server's image store (`crates/server/src/images`)
Expand Down
33 changes: 11 additions & 22 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ members = [
"crates/crypto",
"crates/telemetry",
"crates/traffic",
"crates/plugin-api",
"crates/plugin",
"crates/format-api",
"crates/server",
"crates/e2e",
"crates/formats/apt",
Expand Down
14 changes: 7 additions & 7 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,23 +268,23 @@ password: `docker login localhost:8421 -u admin@example.com`, then
`docker push localhost:8421/docker-hosted/team/app:1`. See
[docs/docker.md](docs/docker.md).

Raw repositories (`"format":"raw"`, the built-in plugin `crates/formats/raw`)
Raw repositories (`"format":"raw"`, the format crate `crates/formats/raw`)
hold files under arbitrary paths, served under `/repository/`: `curl -u
admin@example.com:<token> --upload-file x.tgz
localhost:8421/repository/binaries/tools/x.tgz`, a proxy of any HTTP(S) base URL
(`"remote_url":"https://github.com/<owner>/<repo>/releases/download/"`), and
groups. See [docs/raw.md](docs/raw.md).

### Repositories of other formats (format plugins)
### Repositories of other formats

Every format is a format plugin, a crate in
Every format is a crate in
[`crates/formats/`](./crates/formats) linked into the server (see
[docs/format-plugins.md](./docs/format-plugins.md)): Docker / OCI, NuGet, npm,
raw and apt. A new format goes in `crates/formats/<id>` too, never in a
`crates/format-<id>` crate or the server. Docker / OCI is an images format,
served under `/v2` over the server's image store. A repository of a plugin
format is created with `"format": "<id>"` (hosted, proxy or group, the kinds
the format's descriptor lists: apt has no groups) and served under
served under `/v2` over the server's image store. A repository of a format
is created with `"format": "<id>"` (hosted, proxy or group, the kinds the
format's descriptor lists: apt has no groups) and served under
`/repository/<name>/`. Helm charts are pushed to docker repositories, see
[docs/docker.md](docs/docker.md#helm-charts).

Expand Down Expand Up @@ -1146,7 +1146,7 @@ URLs to local test servers are on purpose, and an alert for each would only
be dismissed by hand. The configuration's `paths-ignore` leaves out the test
directories (`**/tests`, `crates/e2e`, `web/e2e`), the web UI's
`*.test.ts(x)` and test runner configuration, the `testing` helpers of
`crates/plugin` and `crates/plugin-api`, and `fixtures/`. The unit tests
`crates/format-api`, and `fixtures/`. The unit tests
inside the Rust source files (`#[cfg(test)] mod tests`) cannot be left out by
path; the Rust extractor compiles the code with `cfg(test)` off instead
(`CODEQL_EXTRACTOR_RUST_OPTION_CARGO_CFG_OVERRIDES=-test`), as a release build
Expand Down
33 changes: 16 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ token for a short-lived API key
([docs/trusted-publishing.md](docs/trusted-publishing.md), which also covers
its limitations).
A **retention** engine reaps cold versions and garbage-collects dereferenced
blobs. Every format is a **format plugin**: a crate built into the server
that speaks the format's protocol, while the server keeps all of the above
the same for every format ([docs/format-plugins.md](docs/format-plugins.md)).
The admin + browse UI is a **TanStack Router SPA** served by the same Rust
process on a single origin — there is no Node server in production.
blobs. Every format is a **format crate** built into the server: it speaks
the format's protocol, while the server keeps all of the above the same for
every format ([docs/format-plugins.md](docs/format-plugins.md)). The admin +
browse UI is a **TanStack Router SPA** served by the same Rust process on a
single origin — there is no Node server in production.

## Architecture

Expand All @@ -61,22 +61,21 @@ A Cargo workspace of small crates plus the web SPA:
| `crypto` | `forge-crypto` | Secret encryption at rest: the envelope encryption of stored secrets (proxy passwords, realm secrets, signing keys) under a root key. |
| `telemetry` | `forge-telemetry` | OpenTelemetry metrics and the `tracing` spans that become traces. |
| `traffic` | `forge-traffic` | Traffic statistics: requests per repository and client network per day, kept as Parquet files in the object store. |
| `core` | `forge-core` | Domain logic: repo dispatch, auth chain, authz/RBAC, sessions, OIDC, retention. |
| `plugin-api` | `forge-plugin-api` | The format-plugin contract: the `FormatPlugin` trait and its types. |
| `plugin` | `forge-plugin` | The host side of format plugins: the adapter of a plugin to the repository machinery, and the checks of its descriptor. |
| `formats/<id>` | `forge-format-*` | Format plugins, built into the server: `apt`, `docker` (the distribution-spec `/v2` API, manifests, upstream registries), `nuget`, `npm`, `raw`. |
| `server` | `forge-server` | Axum HTTP server: the format plugins' feeds under `/repository/{repo}` (except Docker at `/v2`), the file store of files formats, the image store and registry tokens of images formats, `/admin` + `/auth` REST, background workers, static SPA serving. |
| `core` | `forge-core` | Domain logic: repo dispatch, the formats' adapter to it (`SpecFormat`), auth chain, authz/RBAC, sessions, OIDC, retention. |
| `format-api` | `forge-format-api` | The format contract: the `FormatSpec` trait every format implements, and its types. |
| `formats/<id>` | `forge-format-*` | The formats, built into the server: `apt`, `docker` (the distribution-spec `/v2` API, manifests, upstream registries), `nuget`, `npm`, `raw`. |
| `server` | `forge-server` | Axum HTTP server: the formats' feeds under `/repository/{repo}` (except Docker at `/v2`), the file store of files formats, the image store and registry tokens of images formats, `/admin` + `/auth` REST, background workers, static SPA serving. |
| `e2e` | `forge-e2e` | End-to-end suite (testcontainers + mock upstream/IdP). |
| `xtask` | — | The task runner (`cargo xtask …`); replaces Make/Just. |
| `web/` | — | TanStack Router SPA (pnpm + Vite), built into `web/dist/`. |

**Every format is a plugin in `crates/formats/`.** A format is a crate in
`crates/formats/<id>/` implementing `FormatPlugin`, linked into the server.
Docker / OCI is one too, an images format: the plugin speaks the protocol,
**Every format is a crate in `crates/formats/`.** A format is a crate in
`crates/formats/<id>/` implementing `FormatSpec`, linked into the server.
Docker / OCI is one too, an images format: the crate speaks the protocol,
and the server's image store (`crates/server/src/images`) keeps the
images.

**Single-origin serving.** `forge-server` serves every non-Docker format plugin
**Single-origin serving.** `forge-server` serves every non-Docker format
at `/repository/{repo}`, the Docker / OCI registry at `/v2`,
the admin/browse APIs (`/admin`, `/browse`, `/auth`) and then serves the built SPA
from `web/dist/` via a `ServeDir` SPA fallback. The browser talks to the API on
Expand Down Expand Up @@ -257,9 +256,9 @@ The guides below are also a site, with navigation and search:
pipelines that cannot use workload identity: a read token and a release
token of service accounts in GitLab's CI/CD variables, and how both
methods run side by side.
- **[Format plugins](./docs/format-plugins.md)** — how every format is
built: the `FormatPlugin` contract, what the server does for each format,
and adding a new one.
- **[Format crates](./docs/format-plugins.md)** — how every format is built:
the `FormatSpec` contract, what the server does for each format, and
adding a new one.
- **[Importing repositories](./docs/import.md)** — copy a hosted repository
of Nexus, Artifactory, Verdaccio, a registry or another Forge (NuGet, npm,
Docker, raw, apt) into Forge through the format's own protocol: dry run,
Expand Down
11 changes: 5 additions & 6 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ forge-cache = { path = "../cache" }
# Secret encryption at rest: the proxy upstream fetch decrypts the
# upstream password through the envelope service (the single sanctioned site).
forge-crypto = { path = "../crypto" }
# The format-plugin contract: the upstream conversation of proxy repositories
# (`Format::upstream`) speaks its types. Nothing but std.
forge-plugin-api = { path = "../plugin-api" }
# The contract every format implements (`FormatSpec`): the upstream
# conversation of proxy repositories (`Format::upstream`) speaks its types, and
# `format::SpecFormat` adapts a format to `Format`. Nothing but std.
forge-format-api = { path = "../format-api" }
forge-telemetry = { path = "../telemetry" }

tokio = { workspace = true }
Expand Down Expand Up @@ -73,10 +74,8 @@ flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"] }

[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs", "io-util", "time", "net", "test-util"] }
# NuGet, the format most integration tests use: the built-in plugin and its
# Format (a dev-only cycle through forge-plugin, which Cargo permits).
# NuGet, the format most integration tests use.
forge-format-nuget = { path = "../formats/nuget" }
forge-plugin = { path = "../plugin" }
argon2 = { workspace = true, features = ["password-hash"] }
zip = { version = "9.0", default-features = false, features = ["deflate"] }
testcontainers = "0.27"
Expand Down
4 changes: 2 additions & 2 deletions crates/core/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ pub enum FormatError {
Io(#[from] std::io::Error),
}

/// The text of [`FormatError::InvalidVersion`]: a format plugin's reason
/// names the version itself, so it comes without the value.
/// The text of [`FormatError::InvalidVersion`]: a format's reason names the
/// version itself, so it comes without the value.
fn invalid_version(value: &str, reason: &str) -> String {
if value.is_empty() {
format!("invalid version: {reason}")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
//! What a format's [`Descriptor`] may say: a well-formed id that no other
//! format and none of the server's names takes, its repository kinds once
//! each, plain asset names and content types. A test over the server's built-in formats
//! (`plugins::builtin`) checks them, so a bad descriptor fails `cargo test`.
//! each, plain asset names and content types. A test over the server's
//! formats (`formats::builtin`) checks them, so a bad descriptor fails
//! `cargo test`.

use forge_plugin_api::{Assets, Descriptor, OptionInput, Storage};
use forge_format_api::{Assets, Descriptor, OptionInput, Storage};

/// Format ids a plugin can never take: names the server routes or
/// reserves.
/// Ids a format can never take: names the server routes or reserves.
const RESERVED_IDS: &[&str] = &["v2", "admin", "browse", "auth", "repository"];

/// Check the descriptors of the formats one server holds: each on its own
Expand Down Expand Up @@ -151,7 +151,7 @@ fn validate_assets(assets: &Assets) -> Result<(), String> {

#[cfg(test)]
mod tests {
use forge_plugin_api::{RepositoryKind, Storage, UpstreamToken};
use forge_format_api::{RepositoryKind, Storage, UpstreamToken};

use super::*;

Expand All @@ -164,7 +164,7 @@ mod tests {
assets: Assets::DEFAULT,
upstream_token: UpstreamToken::None,
options: &[],
prereleases: forge_plugin_api::Prereleases::Dash,
prereleases: forge_format_api::Prereleases::Dash,
indexes: false,
};

Expand Down Expand Up @@ -203,7 +203,7 @@ mod tests {

#[test]
fn checks_options_and_indexes() {
use forge_plugin_api::FormatOption;
use forge_format_api::FormatOption;
const OPTION: FormatOption = FormatOption {
name: "distribution",
label: "Distribution",
Expand Down
31 changes: 22 additions & 9 deletions crates/core/src/format.rs → crates/core/src/format/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,23 @@
//! **object-safe**: no generic methods, no `Self`-typed returns. Parsing reads
//! from a `&mut dyn ReadSeek` (formats such as ZIP-based NuGet packages need to
//! seek), keeping the trait usable both with files and in-memory cursors.
//!
//! Every format is a crate under `crates/formats/<id>/` implementing
//! [`FormatSpec`](forge_format_api::FormatSpec) (crate `forge-format-api`):
//! its identity rules, its package parser and its client protocol.
//! [`SpecFormat`] adapts one to `Format`, so hosted publishing, reads, groups
//! and retention work for it unchanged, and [`descriptor::check`] checks
//! what the formats say about themselves.

pub mod descriptor;
mod spec;

pub use spec::{SpecFormat, UploadFormat};

use std::cmp::Ordering;

pub use forge_plugin_api::VersionClass;
use forge_plugin_api::{FetchResult, Remote, UpstreamOp, UpstreamStep};
pub use forge_format_api::VersionClass;
use forge_format_api::{FetchResult, Remote, UpstreamOp, UpstreamStep};

use crate::domain::{FormatError, ParsedUpload};

Expand Down Expand Up @@ -66,8 +78,9 @@ pub trait Format: Send + Sync {

/// [`Self::parse_upload`] for a package staged on disk at `path` (what
/// the hosted publish path parses). The default opens the file and reads
/// it through `parse_upload`; a format that needs the file itself (a
/// format plugin lends it to its sandbox) overrides it.
/// it through `parse_upload`; a format that needs the file itself
/// ([`SpecFormat`], through `FormatSpec::parse_package_file`) overrides
/// it.
fn parse_upload_file(&self, path: &std::path::Path) -> Result<ParsedUpload, FormatError> {
let mut file = std::fs::File::open(path)?;
self.parse_upload(&mut file)
Expand All @@ -94,7 +107,7 @@ pub trait Format: Send + Sync {
/// Retention: the class of a **normalized** version (release, prerelease
/// or snapshot). The default is SemVer's rule: a version with a `-` is a
/// prerelease (build metadata is gone once normalized), any other a
/// release. See [`forge_plugin_api::FormatPlugin::version_class`].
/// release. See [`forge_format_api::FormatSpec::version_class`].
fn version_class(&self, version: &str) -> VersionClass {
if version.contains('-') {
VersionClass::Prerelease
Expand All @@ -105,7 +118,7 @@ pub trait Format: Send + Sync {

/// Retention: the partition of package `id`'s versions within which the
/// keep-N rules count; `None` (the default) keeps the whole package one
/// partition. See [`forge_plugin_api::FormatPlugin::retention_partition`].
/// partition. See [`forge_format_api::FormatSpec::retention_partition`].
fn retention_partition(&self, id: &str, version: &str) -> Option<String> {
let _ = (id, version);
None
Expand Down Expand Up @@ -145,9 +158,9 @@ pub trait Format: Send + Sync {

/// The fields of [`Self::manifest_summary`] of which a hosted repository
/// holds a bounded number of distinct values
/// ([`forge_plugin_api::FormatPlugin::summary_caps`]): a publish or an
/// ([`forge_format_api::FormatSpec::summary_caps`]): a publish or an
/// import that would add a value past one is refused. None by default.
fn summary_caps(&self) -> &'static [forge_plugin_api::SummaryCap] {
fn summary_caps(&self) -> &'static [forge_format_api::SummaryCap] {
&[]
}

Expand Down Expand Up @@ -205,7 +218,7 @@ pub trait Format: Send + Sync {
}

/// One step of the upstream side of a proxy repository
/// ([`forge_plugin_api::FormatPlugin::upstream`]): what to fetch from
/// ([`forge_format_api::FormatSpec::upstream`]): what to fetch from
/// `remote` for `op`, or what came of it. The proxy read path
/// ([`crate::repo::proxy::ProxyRepo`]) performs the fetches and calls
/// again with everything fetched so far. The default has no upstream: a
Expand Down
Loading
Loading