- Rust 94.6%
- Shell 4.7%
- Go 0.4%
- Python 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
* feat(search): use OpenSearch for /search/quick when configured OpenSearchService maintained an artifacts index that nothing ever read. `search_artifacts` and `search_repositories` had no production callers; the only references were source-introspection tests asserting the query was written correctly without executing it. All search traffic went to the PostgreSQL path, so setting OPENSEARCH_URL bought index writes, a cluster to operate and a health dependency, and no query benefit. This is not a performance change — the PostgreSQL search_vector + GIN index from #2871 is fast. It is about what that path cannot do: typo tolerance, relevance ranking with name/path boosts, and accurate deep-pagination totals. Visibility is enforced on `repository_id` against the caller's AccessScope, resolved from PostgreSQL per request, and NEVER on the index's own `is_public` field. That flag goes stale: RepositoryService::update reindexes only the repository document, not the artifact documents belonging to it, so a repository flipped public -> private leaves its artifacts marked public until the next full reindex, and delete orphans them still marked public. Filtering on it would serve private artifacts to anonymous callers. AccessScope is threaded through rather than Option<Vec<Uuid>> so "no restriction" cannot be confused with "restricted to nothing", and an empty allowlist short-circuits before querying rather than relying on how OpenSearch treats an empty terms array. A test pins that the emitted filter never mentions is_public. Any OpenSearch error falls back to PostgreSQL with a warning, so a degraded cluster cannot take search down. With OPENSEARCH_URL unset the handler takes exactly today's path. /search/advanced stays on PostgreSQL: its facets need aggregations. Closes #3670 * test(search): cover the OpenSearch query path and document mapping The coverage gate measured 43% (35/81) on new lines: the first round of tests covered `visibility_filter_clause` in isolation but not the code that builds and sends the query, nor the document -> API mapping. Add nine tests, none needing a database: opensearch_service, backed by wiremock so the assertions are against the request actually put on the wire rather than the function's source text: - an empty allowlist returns no results AND the mock server receives zero requests, pinning that deny-by-default does not depend on how OpenSearch treats an empty `terms` array - a restricted scope puts `terms` on `repository_id` in the request body, and the body never mentions `is_public` - admin scope emits no repository filter - a 503 from the cluster surfaces as an error, so the handler's fallback has something to catch search handler, for `build_search_result_item_from_doc`: - every field maps across - the doc and row mappings agree on `result_type`, since both feed the same response type and a divergence would be visible to clients - a non-UUID document id degrades to the nil UUID instead of dropping the hit - an out-of-range stored timestamp falls back to the epoch - an absent version stays absent rather than becoming an empty string Refs #3670 * fix(search): let PostgreSQL vet OpenSearch hits and bound the query Two gaps in routing `/search/quick` through OpenSearch, both on the request path this PR creates. **PostgreSQL, not the index, decides which hits are real.** The `terms` filter on `repository_id` is the right shape and the reasoning behind not trusting the indexed `is_public` is right, but the index drifts from PostgreSQL in more ways than repository visibility. Several soft-delete paths mark `artifacts.is_deleted` with a direct `UPDATE` rather than going through `ArtifactService::delete_artifact` — Helm chart deletes, Maven and Conan version deletes — so `remove_artifact` never runs and the document survives. The PostgreSQL path filters `a.is_deleted = false` on every query; without a re-check the OpenSearch path is the one place a deleted artifact reappears. So every hit is now vetted against PostgreSQL before it is serialised: the row must exist, must not be soft-deleted, and its repository must be in the caller's scope. One indexed lookup over at most 50 ids, and the cluster's ranking order is preserved. Re-checking the scope is redundant with the `terms` clause by design — it is the gate, and it holds even if the mapping drifts off `keyword`, the cluster truncates an oversized `terms` list, or the index is restored from another deployment's snapshot. **The fallback did not cover the worst failure.** `TransportBuilder` is built without a request timeout, so "unreachable" only degraded gracefully when the cluster failed fast. A cluster that accepts the connection and never answers — a stop-the-world GC pause, a saturated search thread pool, a blackholed route holding the socket open — would hold the handler and its worker indefinitely. The query is now bounded at three seconds, and a timeout falls back to PostgreSQL like any other failure. Four DB-backed tests drive the handler end to end through a wiremock cluster: an artifact in a repository the caller holds no grant on is not served, the same hit with a `read` grant is (so the first is about the filter, not a dead path), a soft-deleted artifact is not served, and a 503 from the cluster degrades to PostgreSQL rather than 500. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6pg381RiaM9GsdVEyrN8M --------- Co-authored-by: brandonrc <brandon.geraci@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
| .assets | ||
| .cargo | ||
| .claude/agents | ||
| .config | ||
| .githooks | ||
| .github | ||
| .sqlx | ||
| .vex | ||
| backend | ||
| docker | ||
| docs | ||
| proof | ||
| scripts | ||
| .clippy.toml | ||
| .dockerignore | ||
| .env.example | ||
| .env.local-dev | ||
| .gitguardian.yaml | ||
| .gitignore | ||
| .jscpd.json | ||
| .mergify.yml | ||
| .trivyignore | ||
| ARCHITECTURE.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| CODE_OF_CONDUCT.md | ||
| CONTRIBUTING.md | ||
| docker-compose.concurrency-e2e.yml | ||
| docker-compose.e2e-syspkg.yml | ||
| docker-compose.local-dev.yml | ||
| docker-compose.mesh-e2e.yml | ||
| docker-compose.s3-maven-test.yml | ||
| docker-compose.test.yml | ||
| docker-compose.yml | ||
| lcov.info | ||
| LICENSE | ||
| README.md | ||
| REDTEAM.md | ||
| RELEASING.md | ||
| rust-toolchain.toml | ||
| rustfmt.toml | ||
| SECURITY.md | ||
| SETUP-CI.md | ||
| TESTING.md | ||
Artifact Keeper
An enterprise-grade, open-source artifact registry supporting 45+ package formats. Built with Rust.
Documentation | Demo | Website
Highlights
- 45+ Package Formats - Native protocol support for Maven, PyPI, NPM, Docker/OCI, Cargo, Go, Helm, and 38 more
- WASM Plugin System - Extend with custom format handlers via WebAssembly (WIT-based, Wasmtime runtime)
- Security Scanning - Automated vulnerability detection with Trivy and Grype, policy engine, quarantine workflow
- Hardened Containers - All images built on DISA STIG-approved Red Hat UBI 9 base images, non-root execution, no shell or package manager in runtime
- Borg Replication - Recursive peer mesh with swarm-based artifact distribution and P2P transfers
- Full-Text Search - OpenSearch-powered search across all repositories and artifacts
- Multi-Auth - JWT, OpenID Connect, LDAP, SAML 2.0, and API token support
- Artifactory Migration - Built-in tooling to migrate repositories, artifacts, and permissions from JFrog Artifactory
- Artifact Signing - GPG and RSA signing integrated into Debian, RPM, Alpine, and Conda handlers
System Architecture
graph LR
Client["CLI / Package Manager / Frontend"]
Backend["Backend<br/>Rust · Axum<br/>45+ format handlers"]
DB[(PostgreSQL 16)]
Storage["Storage<br/>Filesystem / S3"]
Meili["OpenSearch<br/>Full-text search"]
Trivy["Trivy<br/>Container & FS scanning"]
Grype["Grype<br/>Dependency scanning"]
OpenSCAP["OpenSCAP<br/>Compliance scanning"]
Peer1["Peer Instance"]
Peer2["Peer Instance"]
Client --> Backend
Backend --> DB
Backend --> Storage
Backend --> Meili
Backend --> Trivy
Backend --> Grype
Backend --> OpenSCAP
Backend <-->|Borg Replication| Peer1
Backend <-->|Borg Replication| Peer2
Peer1 <-->|P2P Mesh| Peer2
Backend Architecture
The backend follows a layered architecture with a middleware pipeline processing every request.
flowchart TD
REQ["HTTP Request"] --> MW["Middleware Pipeline"]
subgraph MW["Middleware"]
direction LR
CORS["CORS"] --> AUTH["Auth<br/>JWT · OIDC · LDAP<br/>SAML · API Key"]
AUTH --> RL["Rate Limiter"]
RL --> TRACE["Tracing<br/>+ Metrics"]
TRACE --> DEMO["Demo Mode<br/>Guard"]
end
MW --> ROUTER["Router<br/>50+ route groups"]
subgraph HANDLERS["Handler Layer"]
FMT["Format Handlers<br/>Maven · PyPI · NPM<br/>Docker · 41 more"]
CORE["Core Handlers<br/>Repos · Artifacts<br/>Users · Auth"]
ADV["Advanced Handlers<br/>Security · Plugins<br/>Peers · Migration"]
end
ROUTER --> HANDLERS
subgraph SERVICES["Service Layer"]
direction LR
ART["Artifact<br/>Service"]
REPO["Repository<br/>Service"]
SCAN["Scanner<br/>Service"]
PLUG["Plugin<br/>Service"]
SEARCH["Search<br/>Service"]
end
HANDLERS --> SERVICES
subgraph DATA["Data Layer"]
direction LR
PG[(PostgreSQL)]
FS["Storage<br/>FS / S3"]
MS["OpenSearch"]
SC["Trivy / Grype / OpenSCAP"]
end
SERVICES --> DATA
Supported Package Formats
45+ formats organized by ecosystem. Each has a native protocol handler that speaks the package manager's wire protocol.
Languages & Runtimes
| Format | Aliases | Ecosystem |
|---|---|---|
| Maven | Gradle | Java, Kotlin, Scala |
| NPM | Yarn, Bower, pnpm | JavaScript, TypeScript |
| PyPI | Poetry, Conda, JupyterLab Extensions | Python |
| NuGet | Chocolatey, PowerShell | .NET, C# |
| Cargo | Rust | |
| Go | Go modules | |
| RubyGems | Ruby | |
| Hex | Elixir, Erlang | |
| Composer | PHP | |
| Pub | Dart, Flutter | |
| CocoaPods | iOS, macOS | |
| Swift | Swift Package Manager | |
| CRAN | R | |
| SBT | Ivy | Scala, Java |
Containers & Infrastructure
| Format | Aliases | Ecosystem |
|---|---|---|
| Docker / OCI | Podman, Buildx, ORAS, WASM OCI, Helm OCI | Container images |
| Helm | Kubernetes charts | |
| Terraform | OpenTofu | Infrastructure modules |
| Vagrant | VM boxes |
System Packages
| Format | Ecosystem |
|---|---|
| RPM | RHEL, Fedora, CentOS |
| Debian | Ubuntu, Debian |
| Alpine | Alpine Linux (APK) |
| Conda | Conda channels |
| OPKG | OpenWrt, embedded Linux |
Configuration Management
| Format | Ecosystem |
|---|---|
| Chef | Chef Supermarket |
| Puppet | Puppet Forge |
| Ansible | Ansible Galaxy |
ML / AI
| Format | Ecosystem |
|---|---|
| HuggingFace | Models, datasets |
| ML Model | Generic ML artifacts |
Editor Extensions
| Format | Aliases | Ecosystem |
|---|---|---|
| VS Code | Extension marketplace (VS Code, Cursor, Windsurf, Kiro) | |
| JetBrains | Plugin repository |
Schemas
| Format | Ecosystem |
|---|---|
| Protobuf / BSR | Buf Schema Registry, Connect RPC |
Other
| Format | Ecosystem |
|---|---|
| Conan | C, C++ |
| Git LFS | Large file storage |
| Bazel | Bazel modules |
| P2 | Eclipse plugins |
| Generic | Any file type |
Custom formats can be added via the WASM plugin system.
Security Scanning Pipeline
Every artifact upload is automatically scanned for known vulnerabilities.
flowchart LR
UP["Artifact<br/>Upload"] --> HASH{"SHA-256<br/>Dedup"}
HASH -->|New artifact| T["Trivy<br/>FS Scanner"]
HASH -->|New artifact| G["Grype<br/>Dependency Scanner"]
HASH -->|Already scanned| CACHE["Cached<br/>Results"]
T --> SCORE["Vulnerability<br/>Score A-F"]
G --> SCORE
CACHE --> SCORE
SCORE --> POL{"Policy<br/>Engine"}
POL -->|Pass| OK["Stored"]
POL -->|Fail| Q["Quarantined"]
- Dual scanner - Trivy for filesystem/container analysis, Grype for dependency trees
- Scoring - A through F grades based on finding severity and count
- Policies - Configurable rules that block or quarantine artifacts
- Signing - GPG/RSA signing for Debian, RPM, Alpine, and Conda packages
Container-image scanning (
TRIVY_ADAPTER_URL). The basedocker-compose.ymlwiresTRIVY_URLfor the legacy trivy server (filesystem / incus rootfs scanning) only. To get first-class Trivy container-image reports, setTRIVY_ADAPTER_URL(e.g.http://scanner-adapter:8080) so the backend registers the dedicatedImageScanneragainst the in-reposcanner-adapter(Harbor Pluggable Scanner API). Without it, images are still covered by grype (registry mode) but no Trivy image report is produced. Uncomment theTRIVY_ADAPTER_URLline and thescanner-adapterservice indocker-compose.ymlto enable it.The adapter pulls images back from the registry, so it must be told an address of the backend that is reachable from the adapter container —
localhostis not, since the adapter itself listens on:8080in its own network namespace (#3169). When nothing is configured the backend now advertises its own container address automatically; setTRIVY_ADAPTER_REGISTRY_URL(or the sharedAK_GRYPE_REGISTRY_HOST) to override, e.g.http://backend:8080. The automatic derivation is skipped when the adapter's own URL is a loopback address, because that normally means the adapter shares this network namespace (cargo runnext to a local adapter, ornetwork_mode: host). It is not skipped correctly for an adapter container published on loopback (docker run -p 127.0.0.1:8081:8080 …scanner-adapter): there the backend reaches the adapter over loopback but the adapter cannot reach the backend that way, so setTRIVY_ADAPTER_REGISTRY_URLexplicitly. The backend logs which fallback it took on every scan. Give the adapter its own trivy cache volume — reusing the trivy server's root-ownedtrivy_cachefails withpermission deniedonfanal/fanal.dbbecause the adapter runs unprivileged (UID 1001).Not-applicable scanners. A scanner that does not apply to an artifact's format (e.g. the filesystem/incus/openscap scanners on a Docker image) records a
not_applicableresult — a benign terminal state, distinct fromfailed. The scan-list API folds multiplenot_applicableresults for the same artifact into a single summary row (collapsed_not_applicable_count+collapsed_scan_types) so they read as one muted "not applicable" indication rather than N failures.
Borg Replication
Recursive peer-to-peer replication where every node is a full Artifact Keeper instance. No thin caches — each peer runs the same stack and can serve as an origin for other peers.
graph TD
P1["Peer<br/>US-West"]
P2["Peer<br/>EU-Central"]
P3["Peer<br/>AP-Southeast"]
P4["Peer<br/>US-East"]
P1 <-->|"Chunked Transfer"| P2
P1 <-->|"Chunked Transfer"| P4
P2 <-->|"Chunked Transfer"| P3
P3 <-->|"Chunked Transfer"| P4
P1 <-->|"P2P Mesh"| P3
P2 <-->|"P2P Mesh"| P4
- Recursive peers - Every peer is a full instance (backend, DB, storage) that can originate replication to other peers
- Swarm-based distribution - Artifacts replicate across the mesh based on demand
- Chunked transfers - Large artifacts split for reliable delivery over unstable links
- Network-aware scheduling - Bandwidth and latency profiling for optimal routing
WASM Plugin System
Extend Artifact Keeper with custom format handlers compiled to WebAssembly.
- WIT-based interface - Plugins implement a well-defined
FormatHandlercontract - Wasmtime runtime - Sandboxed execution with fuel-based CPU limits and memory caps
- Hot reload - Install, enable, disable, and reload plugins without restart
- Sources - Load from Git repositories or ZIP uploads
Quick Start
Get running in 5 minutes with Docker Compose: Quickstart Guide
Documentation
- Quickstart — Get running in 5 minutes
- Installation — Docker Compose, Windows Service (beta), or build from source
- Configuration — Environment variables reference
- Package Formats — All 45+ supported formats
- Docker Deployment — Production setup guide
Project Structure
artifact-keeper/
├── backend/ # Rust backend (Axum, SQLx, 6,400+ unit tests)
│ ├── src/
│ │ ├── api/ # Handlers, middleware, routes
│ │ ├── formats/ # 45+ format handler implementations
│ │ ├── services/ # Business logic (68 services)
│ │ ├── models/ # Data models (21 types)
│ │ └── storage/ # FS and S3 backends
│ └── migrations/ # 69 PostgreSQL migrations
├── edge/ # Peer replication service (Rust)
├── scripts/ # Test runners, native client tests, stress tests
└── .github/ # CI/CD workflows
Technology Choices
| Layer | Choice | Why |
|---|---|---|
| Backend language | Rust | Memory safety, performance, strong type system |
| Web framework | Axum | Tower middleware ecosystem, async-first |
| Database | PostgreSQL 16 | JSONB for metadata, mature ecosystem |
| Search | OpenSearch | Fast full-text search, easy to operate |
| Security scanning | Trivy + Grype + OpenSCAP | Complementary coverage, industry standard |
| Plugin runtime | Wasmtime | Sandboxed, portable, WIT contract system |
| Storage | Filesystem / S3 | Simple default, cloud-ready upgrade path |
CI/CD Pipeline
Seven GitHub Actions workflows handle testing, publishing, and deployment.
flowchart TD
subgraph TRIGGER["Triggers"]
PUSH["Push / PR<br/>to main"]
TAG["Tag v*"]
CRON["Daily 2 AM UTC"]
SITE_PUSH["Push to site/**"]
end
subgraph CI["ci.yml — Every Push/PR"]
direction TB
LINT["🦀 Lint Rust<br/>fmt + clippy"]
UNIT["🧪 Unit Tests<br/>cargo test --lib"]
INTEG["🔗 Integration Tests<br/>+ PostgreSQL<br/>(main push only)"]
SMOKE["🔥 Smoke E2E<br/>PyPI · npm · Cargo<br/>docker-compose.test.yml"]
AUDIT["🔒 Security Audit<br/>cargo audit"]
CI_OK["✅ CI Complete"]
LINT --> UNIT
LINT --> INTEG
UNIT --> SMOKE
SMOKE --> CI_OK
AUDIT --> CI_OK
end
subgraph DOCKER["docker-publish.yml — Push to main / tags"]
direction TB
BE_BUILD["Backend<br/>amd64 + arm64"]
OS_BUILD["OpenSCAP<br/>amd64 + arm64"]
BE_MERGE["Multi-Arch<br/>Manifest"]
OS_MERGE["Multi-Arch<br/>Manifest"]
BE_BUILD --> BE_MERGE
OS_BUILD --> OS_MERGE
end
subgraph E2E["e2e.yml — Manual / called by release"]
direction TB
PKI["🔐 Setup PKI<br/>TLS + GPG"]
NATIVE["📦 Native Client Tests<br/>10 formats"]
STRESS["🔥 Stress Tests<br/>100 concurrent uploads"]
FAILURE["💥 Failure Tests<br/>crash · db · storage"]
PKI --> NATIVE
NATIVE --> STRESS
NATIVE --> FAILURE
end
subgraph RELEASE["release.yml — Tags v*"]
direction TB
E2E_GATE["🚦 E2E Gate<br/>all formats + stress + failure"]
BINARIES["📦 Build Binaries<br/>linux + macOS<br/>amd64 + arm64"]
GH_RELEASE["🚀 GitHub Release<br/>binaries + checksums"]
E2E_GATE --> BINARIES
BINARIES --> GH_RELEASE
end
subgraph NIGHTLY["scheduled-tests.yml — Daily"]
direction TB
NIGHTLY_E2E["🌙 Nightly Smoke E2E"]
DEP_CHECK["🔍 Dependency Check"]
SEC_SCAN["🔒 Security Scan"]
end
subgraph SITE["site.yml"]
PAGES["📄 Build + Deploy<br/>GitHub Pages"]
end
subgraph AMI["ami-build.yml"]
PACKER["🖥️ Packer Build AMI"]
end
PUSH --> CI
PUSH --> DOCKER
TAG --> RELEASE
TAG --> DOCKER
CRON --> NIGHTLY
SITE_PUSH --> SITE
GH_RELEASE -.->|"on release published"| AMI
classDef trigger fill:#6f42c1,color:#fff,stroke:#6f42c1
classDef ci fill:#2ea44f,color:#fff,stroke:#2ea44f
classDef docker fill:#0969da,color:#fff,stroke:#0969da
classDef release fill:#d97706,color:#fff,stroke:#d97706
class PUSH,TAG,CRON,SITE_PUSH trigger
class LINT,UNIT,INTEG,SMOKE,AUDIT,CI_OK ci
class BE_BUILD,OS_BUILD,BE_MERGE,OS_MERGE docker
class E2E_GATE,BINARIES,GH_RELEASE release
| Workflow | Trigger | What It Does |
|---|---|---|
| ci.yml | Every push/PR | Lint, unit tests, integration tests, smoke E2E (PyPI, npm, Cargo) |
| docker-publish.yml | Push to main, tags | Multi-arch Docker images (backend + OpenSCAP) to ghcr.io |
| e2e.yml | Manual or called by release | Full E2E: 10 native client formats, stress, failure injection |
| release.yml | Tags v* |
E2E gate, cross-platform binaries, GitHub Release |
| scheduled-tests.yml | Daily 2 AM UTC | Nightly smoke E2E, dependency check, security scan |
| site.yml | Push to site/** |
Build and deploy docs to GitHub Pages |
| ami-build.yml | On release published | Bake AWS AMI with Packer |
Sponsors
Thank you to our sponsors for supporting ongoing development of Artifact Keeper.
Backers
![]() Ash A. |
![]() Gabriel Rodriguez |
Become a sponsor to support the project and get your name listed here.
Contributing
We welcome contributions! See CONTRIBUTING.md for guidelines.
Have questions or ideas? Join the conversation in GitHub Discussions.
License
MIT License - see LICENSE for details.
Built with Rust. "JFrog" and "Artifactory" are trademarks of JFrog Ltd. Artifact Keeper is not affiliated with or endorsed by JFrog.

