mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-13 02:40:31 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbff337b4c | |||
| a73335c549 | |||
| ad6aba46b2 | |||
| 095270d113 | |||
| 769f5fb01d | |||
| 53884e143b | |||
| 0eb26f24f7 | |||
| fa962b2d6e | |||
| a1da4fff1e | |||
| 868c4feca7 | |||
| 5b4cba1392 | |||
| ad890be56a | |||
| 3b9ea37b0e | |||
| 233b83f902 | |||
| d886426957 | |||
| 52c2443543 | |||
| 26d30b622d | |||
| 272898f43c | |||
| 61de6c6ddd | |||
| b80c7c5160 | |||
| 68089b2bbf | |||
| af411a2bf4 | |||
| 96ccd16879 | |||
| 6582000789 | |||
| 070774ac94 | |||
| 058fc41f1c | |||
| 7f5a3c7c8a | |||
| 5b57cc5913 | |||
| aa844d851d | |||
| 8569de23d5 | |||
|
|
9349b93757 | ||
|
|
69080dfd90 | ||
|
|
ae799aed94 | ||
|
|
95c6e403a8 | ||
|
|
2c886040d7 | ||
|
|
9ab6ccc594 | ||
|
|
679b36b986 | ||
|
|
da8c473e02 | ||
|
|
3dc8b81261 | ||
| 7502c583d0 |
100
.github/workflows/ci.yml
vendored
100
.github/workflows/ci.yml
vendored
@@ -51,16 +51,14 @@ jobs:
|
||||
run: |
|
||||
curl -sL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz \
|
||||
| tar xz -C /usr/local/bin gitleaks
|
||||
gitleaks detect --source . --exit-code 1 --report-format sarif --report-path gitleaks.sarif || true
|
||||
continue-on-error: true # findings are reported, do not block the pipeline
|
||||
gitleaks detect --source . --exit-code 1 --report-format sarif --report-path gitleaks.sarif
|
||||
|
||||
# ── CVE in Rust dependencies ────────────────────────────────────────────
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit --locked
|
||||
|
||||
- name: cargo audit — RustSec advisory database
|
||||
run: cargo audit
|
||||
continue-on-error: true # warn only; known CVEs should not block CI until triaged
|
||||
run: cargo audit --ignore RUSTSEC-2025-0119 # known: number_prefix via indicatif
|
||||
|
||||
# ── Licenses, banned crates, supply chain policy ────────────────────────
|
||||
- name: cargo deny — licenses and banned crates
|
||||
@@ -72,14 +70,14 @@ jobs:
|
||||
# ── CVE scan of source tree and Cargo.lock ──────────────────────────────
|
||||
- name: Trivy — filesystem scan (Cargo.lock + source)
|
||||
if: always()
|
||||
uses: aquasecurity/trivy-action@0.34.2
|
||||
uses: aquasecurity/trivy-action@0.35.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
format: sarif
|
||||
output: trivy-fs.sarif
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: 0 # warn only; change to 1 to block the pipeline
|
||||
exit-code: 1 # block pipeline on HIGH/CRITICAL vulnerabilities
|
||||
|
||||
- name: Upload Trivy fs results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
@@ -87,3 +85,93 @@ jobs:
|
||||
with:
|
||||
sarif_file: trivy-fs.sarif
|
||||
category: trivy-fs
|
||||
|
||||
integration:
|
||||
name: Integration
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build NORA
|
||||
run: cargo build --release --package nora-registry
|
||||
|
||||
# -- Start NORA --
|
||||
- name: Start NORA
|
||||
run: |
|
||||
NORA_STORAGE_PATH=/tmp/nora-data ./target/release/nora &
|
||||
for i in $(seq 1 15); do
|
||||
curl -sf http://localhost:4000/health && break || sleep 2
|
||||
done
|
||||
curl -sf http://localhost:4000/health | jq .
|
||||
|
||||
# -- Docker push/pull --
|
||||
- name: Configure Docker for insecure registry
|
||||
run: |
|
||||
echo '{"insecure-registries": ["localhost:4000"]}' | sudo tee /etc/docker/daemon.json
|
||||
sudo systemctl restart docker
|
||||
sleep 2
|
||||
|
||||
- name: Docker — push and pull image
|
||||
run: |
|
||||
docker pull alpine:3.20
|
||||
docker tag alpine:3.20 localhost:4000/test/alpine:integration
|
||||
docker push localhost:4000/test/alpine:integration
|
||||
docker rmi localhost:4000/test/alpine:integration
|
||||
docker pull localhost:4000/test/alpine:integration
|
||||
echo "Docker push/pull OK"
|
||||
|
||||
- name: Docker — verify catalog and tags
|
||||
run: |
|
||||
curl -sf http://localhost:4000/v2/_catalog | jq .
|
||||
curl -sf http://localhost:4000/v2/test/alpine/tags/list | jq .
|
||||
|
||||
# -- npm (read-only proxy, no publish support yet) --
|
||||
- name: npm — verify registry endpoint
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/npm/lodash)
|
||||
echo "npm endpoint returned: $STATUS"
|
||||
[ "$STATUS" != "000" ] && echo "npm endpoint OK" || (echo "npm endpoint unreachable" && exit 1)
|
||||
|
||||
# -- Maven deploy/download --
|
||||
- name: Maven — deploy and download artifact
|
||||
run: |
|
||||
echo "test-artifact-content-$(date +%s)" > /tmp/test-artifact.jar
|
||||
CHECKSUM=$(sha256sum /tmp/test-artifact.jar | cut -d' ' -f1)
|
||||
curl -sf -X PUT --data-binary @/tmp/test-artifact.jar http://localhost:4000/maven2/com/example/test-lib/1.0.0/test-lib-1.0.0.jar
|
||||
curl -sf -o /tmp/downloaded.jar http://localhost:4000/maven2/com/example/test-lib/1.0.0/test-lib-1.0.0.jar
|
||||
DOWNLOAD_CHECKSUM=$(sha256sum /tmp/downloaded.jar | cut -d' ' -f1)
|
||||
[ "$CHECKSUM" = "$DOWNLOAD_CHECKSUM" ] && echo "Maven deploy/download OK" || (echo "Checksum mismatch!" && exit 1)
|
||||
|
||||
# -- PyPI (read-only proxy, no upload support yet) --
|
||||
- name: PyPI — verify simple index
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/simple/)
|
||||
echo "PyPI simple index returned: $STATUS"
|
||||
[ "$STATUS" = "200" ] && echo "PyPI endpoint OK" || (echo "Expected 200, got $STATUS" && exit 1)
|
||||
|
||||
# -- Cargo (read-only proxy, no publish support yet) --
|
||||
- name: Cargo — verify registry API responds
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:4000/cargo/api/v1/crates/serde)
|
||||
echo "Cargo API returned: $STATUS"
|
||||
[ "$STATUS" != "000" ] && echo "Cargo endpoint OK" || (echo "Cargo endpoint unreachable" && exit 1)
|
||||
|
||||
# -- API checks --
|
||||
- name: API — health, ready, metrics
|
||||
run: |
|
||||
curl -sf http://localhost:4000/health | jq .status
|
||||
curl -sf http://localhost:4000/ready
|
||||
curl -sf http://localhost:4000/metrics | head -5
|
||||
echo "API checks OK"
|
||||
|
||||
- name: Stop NORA
|
||||
if: always()
|
||||
run: pkill nora || true
|
||||
|
||||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
@@ -39,12 +39,12 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# ── Alpine ───────────────────────────────────────────────────────────────
|
||||
- name: Extract metadata (alpine)
|
||||
id: meta-alpine
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push (alpine)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -72,13 +72,13 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-alpine.outputs.tags }}
|
||||
labels: ${{ steps.meta-alpine.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine
|
||||
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine,ignore-error=true
|
||||
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:alpine,mode=max
|
||||
|
||||
# ── RED OS ───────────────────────────────────────────────────────────────
|
||||
- name: Extract metadata (redos)
|
||||
id: meta-redos
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
type=raw,value=redos
|
||||
|
||||
- name: Build and push (redos)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.redos
|
||||
@@ -98,13 +98,13 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-redos.outputs.tags }}
|
||||
labels: ${{ steps.meta-redos.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:redos
|
||||
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:redos,ignore-error=true
|
||||
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:redos,mode=max
|
||||
|
||||
# ── Astra Linux SE ───────────────────────────────────────────────────────
|
||||
- name: Extract metadata (astra)
|
||||
id: meta-astra
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
type=raw,value=astra
|
||||
|
||||
- name: Build and push (astra)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.astra
|
||||
@@ -124,9 +124,21 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-astra.outputs.tags }}
|
||||
labels: ${{ steps.meta-astra.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra
|
||||
cache-from: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra,ignore-error=true
|
||||
cache-to: type=registry,ref=${{ env.NORA }}/${{ env.IMAGE_NAME }}-cache:astra,mode=max
|
||||
|
||||
# ── Smoke test ──────────────────────────────────────────────────────────
|
||||
- name: Smoke test — verify alpine image starts and responds
|
||||
run: |
|
||||
docker rm -f nora-smoke 2>/dev/null || true
|
||||
docker run --rm -d --name nora-smoke -p 5555:4000 -e NORA_HOST=0.0.0.0 \
|
||||
${{ env.NORA }}/${{ env.IMAGE_NAME }}:latest
|
||||
for i in $(seq 1 10); do
|
||||
curl -sf http://localhost:5555/health && break || sleep 2
|
||||
done
|
||||
curl -sf http://localhost:5555/health
|
||||
docker stop nora-smoke
|
||||
|
||||
scan:
|
||||
name: Scan (${{ matrix.name }})
|
||||
runs-on: [self-hosted, nora]
|
||||
@@ -153,7 +165,7 @@ jobs:
|
||||
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Trivy — image scan (${{ matrix.name }})
|
||||
uses: aquasecurity/trivy-action@0.34.2
|
||||
uses: aquasecurity/trivy-action@0.35.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}${{ matrix.suffix }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,6 +6,9 @@ data/
|
||||
*.log
|
||||
internal config
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
|
||||
# Internal files
|
||||
SESSION*.md
|
||||
TODO.md
|
||||
|
||||
8
.gitleaks.toml
Normal file
8
.gitleaks.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
# Gitleaks configuration
|
||||
# https://github.com/gitleaks/gitleaks
|
||||
|
||||
[allowlist]
|
||||
description = "Allowlist for false positives"
|
||||
|
||||
# Documentation examples with placeholder credentials
|
||||
commits = ["92155cf6574d89f93ee68503a7b68455ceaa19af"]
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -4,6 +4,64 @@ All notable changes to NORA will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.29] - 2026-03-15
|
||||
|
||||
### Added / Добавлено
|
||||
- **Upstream Authentication**: All registry proxies now support Basic Auth credentials for private upstream registries
|
||||
- **Аутентификация upstream**: Все прокси реестров теперь поддерживают Basic Auth для приватных upstream-реестров
|
||||
- Docker: `NORA_DOCKER_UPSTREAMS="https://registry.corp.com|user:pass"`
|
||||
- Maven: `NORA_MAVEN_PROXIES="https://nexus.corp.com/maven2|user:pass"`
|
||||
- npm: `NORA_NPM_PROXY_AUTH="user:pass"`
|
||||
- PyPI: `NORA_PYPI_PROXY_AUTH="user:pass"`
|
||||
- **Plaintext credential warning**: NORA logs a warning at startup if credentials are stored in config.toml instead of env vars
|
||||
- **Предупреждение о plaintext credentials**: NORA логирует предупреждение при старте, если credentials хранятся в config.toml вместо переменных окружения
|
||||
|
||||
### Changed / Изменено
|
||||
- Extracted `basic_auth_header()` helper for consistent auth across all protocols
|
||||
- Вынесен хелпер `basic_auth_header()` для единообразной авторизации всех протоколов
|
||||
|
||||
### Removed / Удалено
|
||||
- Removed unused `DockerAuth::fetch_with_auth()` method (dead code cleanup)
|
||||
- Удалён неиспользуемый метод `DockerAuth::fetch_with_auth()` (очистка мёртвого кода)
|
||||
|
||||
---
|
||||
|
||||
## [0.2.28] - 2026-03-13
|
||||
|
||||
### Fixed / Исправлено
|
||||
- **docker-compose.yml**: Fixed image reference from `getnora/nora:latest` to `ghcr.io/getnora-io/nora:latest`
|
||||
- **docker-compose.yml**: Исправлена ссылка на образ с `getnora/nora:latest` на `ghcr.io/getnora-io/nora:latest`
|
||||
|
||||
### Documentation / Документация
|
||||
- **Authentication Guide**: Added complete auth setup guide in README — htpasswd, API tokens, RBAC roles, curl examples
|
||||
- **Руководство по аутентификации**: Добавлено полное руководство по настройке auth в README — htpasswd, API-токены, RBAC-роли, примеры curl
|
||||
- **FSTEC builds**: Documented `Dockerfile.astra` and `Dockerfile.redos` purpose in README
|
||||
- **Сборки ФСТЭК**: Документировано назначение `Dockerfile.astra` и `Dockerfile.redos` в README
|
||||
- **TLS / HTTPS**: Added reverse proxy setup guide (Caddy, Nginx) and `insecure-registries` Docker config for internal deployments
|
||||
- **TLS / HTTPS**: Добавлено руководство по настройке reverse proxy (Caddy, Nginx) и конфигурация `insecure-registries` Docker для внутренних инсталляций
|
||||
|
||||
### Removed / Удалено
|
||||
- Removed stale `CHANGELOG.md.bak` from repository
|
||||
- Удалён устаревший `CHANGELOG.md.bak` из репозитория
|
||||
|
||||
---
|
||||
|
||||
## [0.2.27] - 2026-03-03
|
||||
|
||||
### Added / Добавлено
|
||||
- **Configurable body limit**: `NORA_BODY_LIMIT_MB` env var (default: `2048` = 2GB) — replaces hardcoded 100MB limit that caused `413 Payload Too Large` on large Docker image push
|
||||
- **Настраиваемый лимит тела запроса**: переменная `NORA_BODY_LIMIT_MB` (по умолчанию: `2048` = 2GB) — заменяет захардкоженный лимит 100MB, вызывавший `413 Payload Too Large` при push больших Docker-образов
|
||||
- **Docker Delete API**: `DELETE /v2/{name}/manifests/{reference}` and `DELETE /v2/{name}/blobs/{digest}` per Docker Registry V2 spec (returns 202 Accepted)
|
||||
- **Docker Delete API**: `DELETE /v2/{name}/manifests/{reference}` и `DELETE /v2/{name}/blobs/{digest}` по спецификации Docker Registry V2 (возвращает 202 Accepted)
|
||||
- Namespace-qualified DELETE variants (`/v2/{ns}/{name}/...`)
|
||||
- Audit log integration for delete operations
|
||||
|
||||
### Fixed / Исправлено
|
||||
- Docker push of images >100MB no longer fails with 413 error
|
||||
- Push Docker-образов >100MB больше не падает с ошибкой 413
|
||||
|
||||
---
|
||||
|
||||
## [0.2.26] - 2026-03-03
|
||||
|
||||
### Added / Добавлено
|
||||
|
||||
414
CHANGELOG.md.bak
414
CHANGELOG.md.bak
@@ -1,414 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to NORA will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.18] - 2026-01-31
|
||||
|
||||
### Changed
|
||||
- Logo styling refinements
|
||||
|
||||
---
|
||||
|
||||
## [0.2.17] - 2026-01-31
|
||||
|
||||
### Added
|
||||
- Copyright headers to all source files (Volkov Pavel | DevITWay)
|
||||
- SPDX-License-Identifier: MIT in all .rs files
|
||||
|
||||
---
|
||||
|
||||
## [0.2.16] - 2026-01-31
|
||||
|
||||
### Changed
|
||||
- N○RA branding: stylized O logo across dashboard
|
||||
- Fixed O letter alignment in logo
|
||||
|
||||
---
|
||||
|
||||
## [0.2.15] - 2026-01-31
|
||||
|
||||
### Fixed
|
||||
- Code formatting (cargo fmt)
|
||||
|
||||
---
|
||||
|
||||
## [0.2.14] - 2026-01-31
|
||||
|
||||
### Fixed
|
||||
- Docker dashboard now shows actual image size from manifest layers (config + layers sum)
|
||||
- Previously showed only manifest file size (~500 B instead of actual image size)
|
||||
|
||||
---
|
||||
|
||||
## [0.2.13] - 2026-01-31
|
||||
|
||||
### Fixed
|
||||
- npm dashboard now shows correct version count and package sizes
|
||||
- Parses metadata.json for versions, dist.unpackedSize, and time.modified
|
||||
- Previously showed 0 versions / 0 B for all packages
|
||||
|
||||
---
|
||||
|
||||
## [0.2.12] - 2026-01-30
|
||||
|
||||
### Added
|
||||
|
||||
#### Configurable Rate Limiting
|
||||
- Rate limits now configurable via `config.toml` and environment variables
|
||||
- New config section `[rate_limit]` with parameters: `auth_rps`, `auth_burst`, `upload_rps`, `upload_burst`, `general_rps`, `general_burst`
|
||||
- Environment variables: `NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}`
|
||||
|
||||
#### Secrets Provider Architecture
|
||||
- Trait-based secrets management (`SecretsProvider` trait)
|
||||
- ENV provider as default (12-Factor App pattern)
|
||||
- Protected secrets with `zeroize` (memory zeroed on drop)
|
||||
- Redacted Debug impl prevents secret leakage in logs
|
||||
- New config section `[secrets]` with `provider` and `clear_env` options
|
||||
|
||||
#### Docker Image Metadata
|
||||
- Support for image metadata retrieval
|
||||
|
||||
#### Documentation
|
||||
- Bilingual onboarding guide (EN/RU)
|
||||
|
||||
---
|
||||
|
||||
## [0.2.11] - 2026-01-26
|
||||
|
||||
### Added
|
||||
- Internationalization (i18n) support
|
||||
- PyPI registry proxy
|
||||
- UI improvements
|
||||
|
||||
---
|
||||
|
||||
## [0.2.10] - 2026-01-26
|
||||
|
||||
### Changed
|
||||
- Dark theme applied to all UI pages
|
||||
|
||||
---
|
||||
|
||||
## [0.2.9] - 2026-01-26
|
||||
|
||||
### Changed
|
||||
- Version bump release
|
||||
|
||||
---
|
||||
|
||||
## [0.2.8] - 2026-01-26
|
||||
|
||||
### Added
|
||||
- Dashboard endpoint added to OpenAPI documentation
|
||||
|
||||
---
|
||||
|
||||
## [0.2.7] - 2026-01-26
|
||||
|
||||
### Added
|
||||
- Dynamic version display in UI sidebar
|
||||
|
||||
---
|
||||
|
||||
## [0.2.6] - 2026-01-26
|
||||
|
||||
### Added
|
||||
|
||||
#### Dashboard Metrics
|
||||
- Global stats panel: downloads, uploads, artifacts, cache hit rate, storage
|
||||
- Extended registry cards with artifact count, size, counters
|
||||
- Activity log (last 20 events)
|
||||
|
||||
#### UI
|
||||
- Dark theme (bg: #0f172a, cards: #1e293b)
|
||||
|
||||
---
|
||||
|
||||
## [0.2.5] - 2026-01-26
|
||||
|
||||
### Fixed
|
||||
- Docker push/pull: added PATCH endpoint for chunked uploads
|
||||
|
||||
---
|
||||
|
||||
## [0.2.4] - 2026-01-26
|
||||
|
||||
### Fixed
|
||||
- Rate limiting: health/metrics endpoints now exempt
|
||||
- Increased upload rate limits for Docker parallel requests
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] - 2026-01-25
|
||||
|
||||
### Added
|
||||
|
||||
#### UI: SVG Brand Icons
|
||||
- Replaced emoji icons with proper SVG brand icons (Simple Icons style)
|
||||
- Docker, Maven, npm, Cargo, PyPI icons now render as scalable vector graphics
|
||||
- Consistent icon styling across dashboard, sidebar, and detail pages
|
||||
|
||||
#### Testing Infrastructure
|
||||
- Unit tests for LocalStorage (8 tests): put/get, list, stat, health_check
|
||||
- Unit tests for S3Storage with wiremock HTTP mocking (11 tests)
|
||||
- Integration tests for auth/htpasswd (7 tests)
|
||||
- Token lifecycle tests (11 tests)
|
||||
- Validation tests (21 tests)
|
||||
- **Total: 75 tests passing**
|
||||
|
||||
#### Security: Input Validation (`validation.rs`)
|
||||
- Path traversal protection: rejects `../`, `..\\`, null bytes, absolute paths
|
||||
- Docker image name validation per OCI distribution spec
|
||||
- Content digest validation (`sha256:[64 hex]`, `sha512:[128 hex]`)
|
||||
- Docker tag/reference validation
|
||||
- Storage key length limits (max 1024 chars)
|
||||
|
||||
#### Security: Rate Limiting (`rate_limit.rs`)
|
||||
- Auth endpoints: 1 req/sec, burst 5 (brute-force protection)
|
||||
- Upload endpoints: 10 req/sec, burst 20
|
||||
- General endpoints: 100 req/sec, burst 200
|
||||
- Uses `tower_governor` 0.8 with `PeerIpKeyExtractor`
|
||||
|
||||
#### Observability: Request ID Tracking (`request_id.rs`)
|
||||
- `X-Request-ID` header added to all responses
|
||||
- Accepts upstream request ID or generates UUID v4
|
||||
- Tracing spans include request_id for log correlation
|
||||
|
||||
#### CLI: Migrate Command (`migrate.rs`)
|
||||
- `nora migrate --from local --to s3` - migrate between storage backends
|
||||
- `--dry-run` flag for preview without copying
|
||||
- Progress bar with indicatif
|
||||
- Skips existing files in destination
|
||||
- Summary statistics (migrated, skipped, failed, bytes)
|
||||
|
||||
#### Error Handling (`error.rs`)
|
||||
- `AppError` enum with `IntoResponse` for Axum
|
||||
- Automatic conversion from `StorageError` and `ValidationError`
|
||||
- JSON error responses with request_id support
|
||||
|
||||
### Changed
|
||||
- `StorageError` now uses `thiserror` derive macro
|
||||
- `TokenError` now uses `thiserror` derive macro
|
||||
- Storage wrapper validates keys before delegating to backend
|
||||
- Docker registry handlers validate name, digest, reference inputs
|
||||
- Body size limit set to 100MB default via `DefaultBodyLimit`
|
||||
|
||||
### Dependencies Added
|
||||
- `thiserror = "2"` - typed error handling
|
||||
- `tower_governor = "0.8"` - rate limiting
|
||||
- `governor = "0.10"` - rate limiting backend
|
||||
- `tempfile = "3"` (dev) - temporary directories for tests
|
||||
- `wiremock = "0.6"` (dev) - HTTP mocking for S3 tests
|
||||
|
||||
### Files Added
|
||||
- `src/validation.rs` - input validation module
|
||||
- `src/migrate.rs` - storage migration module
|
||||
- `src/error.rs` - application error types
|
||||
- `src/request_id.rs` - request ID middleware
|
||||
- `src/rate_limit.rs` - rate limiting configuration
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2026-01-24
|
||||
|
||||
### Added
|
||||
- Multi-protocol support: Docker Registry v2, Maven, npm, Cargo, PyPI
|
||||
- Web UI dashboard
|
||||
- Swagger UI (`/api-docs`)
|
||||
- Storage backends: Local filesystem, S3-compatible
|
||||
- Smart proxy/cache for Maven and npm
|
||||
- Health checks (`/health`, `/ready`)
|
||||
- Basic authentication (htpasswd with bcrypt)
|
||||
- API tokens (revocable, per-user)
|
||||
- Prometheus metrics (`/metrics`)
|
||||
- JSON structured logging
|
||||
- Environment variable configuration
|
||||
- Graceful shutdown (SIGTERM/SIGINT)
|
||||
- Backup/restore commands
|
||||
|
||||
---
|
||||
|
||||
# Журнал изменений (RU)
|
||||
|
||||
Все значимые изменения NORA документируются в этом файле.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.12] - 2026-01-30
|
||||
|
||||
### Добавлено
|
||||
|
||||
#### Настраиваемый Rate Limiting
|
||||
- Rate limits настраиваются через `config.toml` и переменные окружения
|
||||
- Новая секция `[rate_limit]` с параметрами: `auth_rps`, `auth_burst`, `upload_rps`, `upload_burst`, `general_rps`, `general_burst`
|
||||
- Переменные окружения: `NORA_RATE_LIMIT_{AUTH|UPLOAD|GENERAL}_{RPS|BURST}`
|
||||
|
||||
#### Архитектура Secrets Provider
|
||||
- Trait-based управление секретами (`SecretsProvider` trait)
|
||||
- ENV provider по умолчанию (12-Factor App паттерн)
|
||||
- Защищённые секреты с `zeroize` (память обнуляется при drop)
|
||||
- Redacted Debug impl предотвращает утечку секретов в логи
|
||||
- Новая секция `[secrets]` с опциями `provider` и `clear_env`
|
||||
|
||||
#### Docker Image Metadata
|
||||
- Поддержка получения метаданных образов
|
||||
|
||||
#### Документация
|
||||
- Двуязычный onboarding guide (EN/RU)
|
||||
|
||||
---
|
||||
|
||||
## [0.2.11] - 2026-01-26
|
||||
|
||||
### Добавлено
|
||||
- Поддержка интернационализации (i18n)
|
||||
- PyPI registry proxy
|
||||
- Улучшения UI
|
||||
|
||||
---
|
||||
|
||||
## [0.2.10] - 2026-01-26
|
||||
|
||||
### Изменено
|
||||
- Тёмная тема применена ко всем страницам UI
|
||||
|
||||
---
|
||||
|
||||
## [0.2.9] - 2026-01-26
|
||||
|
||||
### Изменено
|
||||
- Релиз с обновлением версии
|
||||
|
||||
---
|
||||
|
||||
## [0.2.8] - 2026-01-26
|
||||
|
||||
### Добавлено
|
||||
- Dashboard endpoint добавлен в OpenAPI документацию
|
||||
|
||||
---
|
||||
|
||||
## [0.2.7] - 2026-01-26
|
||||
|
||||
### Добавлено
|
||||
- Динамическое отображение версии в сайдбаре UI
|
||||
|
||||
---
|
||||
|
||||
## [0.2.6] - 2026-01-26
|
||||
|
||||
### Добавлено
|
||||
|
||||
#### Dashboard Metrics
|
||||
- Глобальная панель статистики: downloads, uploads, artifacts, cache hit rate, storage
|
||||
- Расширенные карточки реестров с количеством артефактов, размером, счётчиками
|
||||
- Лог активности (последние 20 событий)
|
||||
|
||||
#### UI
|
||||
- Тёмная тема (bg: #0f172a, cards: #1e293b)
|
||||
|
||||
---
|
||||
|
||||
## [0.2.5] - 2026-01-26
|
||||
|
||||
### Исправлено
|
||||
- Docker push/pull: добавлен PATCH endpoint для chunked uploads
|
||||
|
||||
---
|
||||
|
||||
## [0.2.4] - 2026-01-26
|
||||
|
||||
### Исправлено
|
||||
- Rate limiting: health/metrics endpoints теперь исключены
|
||||
- Увеличены лимиты upload для параллельных Docker запросов
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] - 2026-01-25
|
||||
|
||||
### Добавлено
|
||||
|
||||
#### UI: SVG иконки брендов
|
||||
- Эмоджи заменены на SVG иконки брендов (стиль Simple Icons)
|
||||
- Docker, Maven, npm, Cargo, PyPI теперь отображаются как векторная графика
|
||||
- Единый стиль иконок на дашборде, сайдбаре и страницах деталей
|
||||
|
||||
#### Тестовая инфраструктура
|
||||
- Unit-тесты для LocalStorage (8 тестов): put/get, list, stat, health_check
|
||||
- Unit-тесты для S3Storage с HTTP-мокированием wiremock (11 тестов)
|
||||
- Интеграционные тесты auth/htpasswd (7 тестов)
|
||||
- Тесты жизненного цикла токенов (11 тестов)
|
||||
- Тесты валидации (21 тест)
|
||||
- **Всего: 75 тестов проходят**
|
||||
|
||||
#### Безопасность: Валидация ввода (`validation.rs`)
|
||||
- Защита от path traversal: отклоняет `../`, `..\\`, null-байты, абсолютные пути
|
||||
- Валидация имён Docker-образов по спецификации OCI distribution
|
||||
- Валидация дайджестов (`sha256:[64 hex]`, `sha512:[128 hex]`)
|
||||
- Валидация тегов и ссылок Docker
|
||||
- Ограничение длины ключей хранилища (макс. 1024 символа)
|
||||
|
||||
#### Безопасность: Rate Limiting (`rate_limit.rs`)
|
||||
- Auth endpoints: 1 req/sec, burst 5 (защита от брутфорса)
|
||||
- Upload endpoints: 10 req/sec, burst 20
|
||||
- Общие endpoints: 100 req/sec, burst 200
|
||||
- Использует `tower_governor` 0.8 с `PeerIpKeyExtractor`
|
||||
|
||||
#### Наблюдаемость: Отслеживание Request ID (`request_id.rs`)
|
||||
- Заголовок `X-Request-ID` добавляется ко всем ответам
|
||||
- Принимает upstream request ID или генерирует UUID v4
|
||||
- Tracing spans включают request_id для корреляции логов
|
||||
|
||||
#### CLI: Команда миграции (`migrate.rs`)
|
||||
- `nora migrate --from local --to s3` - миграция между storage backends
|
||||
- Флаг `--dry-run` для предпросмотра без копирования
|
||||
- Прогресс-бар с indicatif
|
||||
- Пропуск существующих файлов в destination
|
||||
- Итоговая статистика (migrated, skipped, failed, bytes)
|
||||
|
||||
#### Обработка ошибок (`error.rs`)
|
||||
- Enum `AppError` с `IntoResponse` для Axum
|
||||
- Автоматическая конверсия из `StorageError` и `ValidationError`
|
||||
- JSON-ответы об ошибках с поддержкой request_id
|
||||
|
||||
### Изменено
|
||||
- `StorageError` теперь использует макрос `thiserror`
|
||||
- `TokenError` теперь использует макрос `thiserror`
|
||||
- Storage wrapper валидирует ключи перед делегированием backend
|
||||
- Docker registry handlers валидируют name, digest, reference
|
||||
- Лимит размера body установлен в 100MB через `DefaultBodyLimit`
|
||||
|
||||
### Добавлены зависимости
|
||||
- `thiserror = "2"` - типизированная обработка ошибок
|
||||
- `tower_governor = "0.8"` - rate limiting
|
||||
- `governor = "0.10"` - backend для rate limiting
|
||||
- `tempfile = "3"` (dev) - временные директории для тестов
|
||||
- `wiremock = "0.6"` (dev) - HTTP-мокирование для S3 тестов
|
||||
|
||||
### Добавлены файлы
|
||||
- `src/validation.rs` - модуль валидации ввода
|
||||
- `src/migrate.rs` - модуль миграции хранилища
|
||||
- `src/error.rs` - типы ошибок приложения
|
||||
- `src/request_id.rs` - middleware для request ID
|
||||
- `src/rate_limit.rs` - конфигурация rate limiting
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 2026-01-24
|
||||
|
||||
### Добавлено
|
||||
- Мульти-протокольная поддержка: Docker Registry v2, Maven, npm, Cargo, PyPI
|
||||
- Web UI дашборд
|
||||
- Swagger UI (`/api-docs`)
|
||||
- Storage backends: локальная файловая система, S3-совместимое хранилище
|
||||
- Умный прокси/кэш для Maven и npm
|
||||
- Health checks (`/health`, `/ready`)
|
||||
- Базовая аутентификация (htpasswd с bcrypt)
|
||||
- API токены (отзываемые, per-user)
|
||||
- Prometheus метрики (`/metrics`)
|
||||
- JSON структурированное логирование
|
||||
- Конфигурация через переменные окружения
|
||||
- Graceful shutdown (SIGTERM/SIGINT)
|
||||
- Команды backup/restore
|
||||
36
Cargo.lock
generated
36
Cargo.lock
generated
@@ -190,13 +190,13 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a0f5948f30df5f43ac29d310b7476793be97c50787e6ef4a63d960a0d0be827"
|
||||
checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blowfish",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.1",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -473,7 +473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1247,7 +1247,7 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "nora-cli"
|
||||
version = "0.2.27"
|
||||
version = "0.2.29"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"flate2",
|
||||
@@ -1261,7 +1261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nora-registry"
|
||||
version = "0.2.27"
|
||||
version = "0.2.29"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -1299,7 +1299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nora-storage"
|
||||
version = "0.2.27"
|
||||
version = "0.2.29"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
@@ -1542,9 +1542,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
@@ -1779,7 +1779,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2068,7 +2068,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2147,9 +2147,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
version = "1.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -2209,9 +2209,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.0.3+spec-1.1.0"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c"
|
||||
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
@@ -2533,9 +2533,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.21.0"
|
||||
version = "1.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
|
||||
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||
dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"js-sys",
|
||||
@@ -2741,7 +2741,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -7,7 +7,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.27"
|
||||
version = "0.2.29"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["DevITWay <devitway@gmail.com>"]
|
||||
|
||||
150
README.md
150
README.md
@@ -1,11 +1,11 @@
|
||||
<img src="logo.jpg" alt="NORA" height="120" />
|
||||
|
||||
|
||||
[](LICENSE)
|
||||
[](https://github.com/getnora-io/nora/releases)
|
||||
[](https://github.com/getnora-io/nora/actions)
|
||||
[](https://github.com/getnora-io/nora/pkgs/container/nora)
|
||||
[](https://github.com/getnora-io/nora/stargazers)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://t.me/DevITWay)
|
||||
[](https://getnora.dev)
|
||||
[](https://t.me/getnora)
|
||||
|
||||
> **Your Cloud-Native Artifact Registry**
|
||||
|
||||
@@ -36,8 +36,10 @@ Fast. Organized. Feel at Home.
|
||||
|
||||
- **Security**
|
||||
- Basic Auth (htpasswd + bcrypt)
|
||||
- Revocable API tokens
|
||||
- Revocable API tokens with RBAC
|
||||
- ENV-based configuration (12-Factor)
|
||||
- SBOM (SPDX + CycloneDX) in every release
|
||||
- See [SECURITY.md](SECURITY.md) for vulnerability reporting
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -86,6 +88,39 @@ npm config set registry http://localhost:4000/npm/
|
||||
npm publish
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
NORA supports Basic Auth (htpasswd) and revocable API tokens with RBAC.
|
||||
|
||||
### Quick Setup
|
||||
|
||||
```bash
|
||||
# 1. Create htpasswd file with bcrypt
|
||||
htpasswd -cbB users.htpasswd admin yourpassword
|
||||
# Add more users:
|
||||
htpasswd -bB users.htpasswd ci-user ci-secret
|
||||
|
||||
# 2. Start NORA with auth enabled
|
||||
docker run -d -p 4000:4000 \
|
||||
-v nora-data:/data \
|
||||
-v ./users.htpasswd:/data/users.htpasswd \
|
||||
-e NORA_AUTH_ENABLED=true \
|
||||
ghcr.io/getnora-io/nora:latest
|
||||
|
||||
# 3. Verify
|
||||
curl -u admin:yourpassword http://localhost:4000/v2/_catalog
|
||||
```
|
||||
|
||||
### API Tokens (RBAC)
|
||||
|
||||
| Role | Pull/Read | Push/Write | Delete/Admin |
|
||||
|------|-----------|------------|--------------|
|
||||
| `read` | Yes | No | No |
|
||||
| `write` | Yes | Yes | No |
|
||||
| `admin` | Yes | Yes | Yes |
|
||||
|
||||
See [Authentication guide](https://getnora.dev/configuration/authentication/) for token management, Docker login, and CI/CD integration.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
@@ -105,18 +140,10 @@ nora migrate --from local --to s3
|
||||
| `NORA_HOST` | 127.0.0.1 | Bind address |
|
||||
| `NORA_PORT` | 4000 | Port |
|
||||
| `NORA_STORAGE_MODE` | local | `local` or `s3` |
|
||||
| `NORA_STORAGE_PATH` | data/storage | Local storage path |
|
||||
| `NORA_STORAGE_S3_URL` | - | S3 endpoint URL |
|
||||
| `NORA_STORAGE_BUCKET` | registry | S3 bucket name |
|
||||
| `NORA_AUTH_ENABLED` | false | Enable authentication |
|
||||
| `NORA_RATE_LIMIT_AUTH_RPS` | 1 | Auth requests per second |
|
||||
| `NORA_RATE_LIMIT_AUTH_BURST` | 5 | Auth burst size |
|
||||
| `NORA_RATE_LIMIT_UPLOAD_RPS` | 200 | Upload requests per second |
|
||||
| `NORA_RATE_LIMIT_UPLOAD_BURST` | 500 | Upload burst size |
|
||||
| `NORA_RATE_LIMIT_GENERAL_RPS` | 100 | General requests per second |
|
||||
| `NORA_RATE_LIMIT_GENERAL_BURST` | 200 | General burst size |
|
||||
| `NORA_SECRETS_PROVIDER` | env | Secrets provider (`env`) |
|
||||
| `NORA_SECRETS_CLEAR_ENV` | false | Clear env vars after reading |
|
||||
| `NORA_DOCKER_UPSTREAMS` | `https://registry-1.docker.io` | Docker upstreams (`url\|user:pass,...`) |
|
||||
|
||||
See [full configuration reference](https://getnora.dev/configuration/settings/) for all environment variables including storage, rate limiting, proxy auth, and secrets.
|
||||
|
||||
### config.toml
|
||||
|
||||
@@ -133,24 +160,10 @@ path = "data/storage"
|
||||
enabled = false
|
||||
htpasswd_file = "users.htpasswd"
|
||||
|
||||
[rate_limit]
|
||||
# Strict limits for authentication (brute-force protection)
|
||||
auth_rps = 1
|
||||
auth_burst = 5
|
||||
# High limits for CI/CD upload workloads
|
||||
upload_rps = 200
|
||||
upload_burst = 500
|
||||
# Balanced limits for general API endpoints
|
||||
general_rps = 100
|
||||
general_burst = 200
|
||||
|
||||
[secrets]
|
||||
# Provider: env (default), aws-secrets, vault, k8s (coming soon)
|
||||
provider = "env"
|
||||
# Clear environment variables after reading (security hardening)
|
||||
clear_env = false
|
||||
```
|
||||
|
||||
See [full config reference](https://getnora.dev/configuration/settings/) for rate limiting, secrets, and proxy settings.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| URL | Description |
|
||||
@@ -166,6 +179,77 @@ clear_env = false
|
||||
| `/cargo/` | Cargo |
|
||||
| `/simple/` | PyPI |
|
||||
|
||||
## TLS / HTTPS
|
||||
|
||||
NORA serves plain HTTP by design. **TLS is intentionally not built into the binary** — this is a deliberate architectural decision:
|
||||
|
||||
- **Single responsibility**: NORA manages artifacts, not certificates. Embedding TLS means bundling Let's Encrypt clients, certificate renewal logic, ACME challenges, and custom CA support — all of which already exist in battle-tested tools.
|
||||
- **Operational simplicity**: One place for certificates (reverse proxy), not scattered across every service. When a cert expires, you fix it in one config — not in NORA, Grafana, GitLab, and every other service separately.
|
||||
- **Industry standard**: Docker Hub, GitHub Container Registry, AWS ECR, Harbor, Nexus — none of them terminate TLS in the registry process. A reverse proxy in front is the universal pattern.
|
||||
- **Zero-config internal use**: On trusted networks (lab, CI/CD), NORA works out of the box without generating self-signed certs or managing keystores.
|
||||
|
||||
### Production (recommended): reverse proxy with auto-TLS
|
||||
|
||||
```
|
||||
Client → Caddy/Nginx (HTTPS, port 443) → NORA (HTTP, port 4000)
|
||||
```
|
||||
|
||||
Caddy example:
|
||||
|
||||
```
|
||||
registry.example.com {
|
||||
reverse_proxy localhost:4000
|
||||
}
|
||||
```
|
||||
|
||||
Nginx example:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name registry.example.com;
|
||||
ssl_certificate /etc/letsencrypt/live/registry.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/registry.example.com/privkey.pem;
|
||||
client_max_body_size 0; # unlimited for large image pushes
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Internal / Lab: insecure registry
|
||||
|
||||
If you run NORA without TLS (e.g., on a private network), configure Docker to trust it:
|
||||
|
||||
```json
|
||||
// /etc/docker/daemon.json
|
||||
{
|
||||
"insecure-registries": ["192.168.1.100:4000"]
|
||||
}
|
||||
```
|
||||
|
||||
Then restart Docker:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
> **Note:** `insecure-registries` disables TLS verification for that host. Use only on trusted networks.
|
||||
|
||||
## FSTEC-Certified OS Builds
|
||||
|
||||
NORA provides dedicated Dockerfiles for Russian FSTEC-certified operating systems:
|
||||
|
||||
- `Dockerfile.astra` — Astra Linux SE (for government and defense sector)
|
||||
- `Dockerfile.redos` — RED OS (for enterprise and public sector)
|
||||
|
||||
Both use `scratch` base with statically-linked binary for minimal attack surface. Comments in each file show how to switch to official distro base images if required by your security policy.
|
||||
|
||||
These builds are published as `-astra` and `-redos` tagged images in GitHub Releases.
|
||||
|
||||
## Performance
|
||||
|
||||
| Metric | NORA | Nexus | JFrog |
|
||||
@@ -178,7 +262,7 @@ clear_env = false
|
||||
|
||||
**Created and maintained by [DevITWay](https://github.com/devitway)**
|
||||
|
||||
- Website: [devopsway.ru](https://devopsway.ru)
|
||||
- Website: [getnora.dev](https://getnora.dev)
|
||||
- Telegram: [@DevITWay](https://t.me/DevITWay)
|
||||
- GitHub: [@devitway](https://github.com/devitway)
|
||||
- Email: devitway@gmail.com
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# Vulnerability database (RustSec)
|
||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
ignore = [
|
||||
"RUSTSEC-2025-0119", # number_prefix unmaintained, transitive via indicatif; no fix available
|
||||
"RUSTSEC-2025-0119", # number_prefix unmaintained via indicatif; no fix available. Review by 2026-06-15
|
||||
]
|
||||
|
||||
[licenses]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
nora:
|
||||
build: .
|
||||
image: getnora/nora:latest
|
||||
image: ghcr.io/getnora-io/nora:latest
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
|
||||
@@ -28,7 +28,7 @@ hmac.workspace = true
|
||||
hex.workspace = true
|
||||
toml = "1.0"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
bcrypt = "0.18"
|
||||
bcrypt = "0.19"
|
||||
base64 = "0.22"
|
||||
prometheus = "0.14"
|
||||
lazy_static = "1.5"
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
|
||||
pub use crate::secrets::SecretsConfig;
|
||||
|
||||
/// Encode "user:pass" into a Basic Auth header value, e.g. "Basic dXNlcjpwYXNz".
|
||||
pub fn basic_auth_header(credentials: &str) -> String {
|
||||
format!("Basic {}", STANDARD.encode(credentials))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
@@ -93,7 +99,7 @@ fn default_bucket() -> String {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MavenConfig {
|
||||
#[serde(default)]
|
||||
pub proxies: Vec<String>,
|
||||
pub proxies: Vec<MavenProxyEntry>,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub proxy_timeout: u64,
|
||||
}
|
||||
@@ -102,6 +108,8 @@ pub struct MavenConfig {
|
||||
pub struct NpmConfig {
|
||||
#[serde(default)]
|
||||
pub proxy: Option<String>,
|
||||
#[serde(default)]
|
||||
pub proxy_auth: Option<String>, // "user:pass" for basic auth
|
||||
#[serde(default = "default_timeout")]
|
||||
pub proxy_timeout: u64,
|
||||
}
|
||||
@@ -110,6 +118,8 @@ pub struct NpmConfig {
|
||||
pub struct PypiConfig {
|
||||
#[serde(default)]
|
||||
pub proxy: Option<String>,
|
||||
#[serde(default)]
|
||||
pub proxy_auth: Option<String>, // "user:pass" for basic auth
|
||||
#[serde(default = "default_timeout")]
|
||||
pub proxy_timeout: u64,
|
||||
}
|
||||
@@ -131,6 +141,37 @@ pub struct DockerUpstream {
|
||||
pub auth: Option<String>, // "user:pass" for basic auth
|
||||
}
|
||||
|
||||
/// Maven upstream proxy configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum MavenProxyEntry {
|
||||
Simple(String),
|
||||
Full(MavenProxy),
|
||||
}
|
||||
|
||||
/// Maven upstream proxy with optional auth
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MavenProxy {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub auth: Option<String>, // "user:pass" for basic auth
|
||||
}
|
||||
|
||||
impl MavenProxyEntry {
|
||||
pub fn url(&self) -> &str {
|
||||
match self {
|
||||
MavenProxyEntry::Simple(s) => s,
|
||||
MavenProxyEntry::Full(p) => &p.url,
|
||||
}
|
||||
}
|
||||
pub fn auth(&self) -> Option<&str> {
|
||||
match self {
|
||||
MavenProxyEntry::Simple(_) => None,
|
||||
MavenProxyEntry::Full(p) => p.auth.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw repository configuration for simple file storage
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RawConfig {
|
||||
@@ -177,7 +218,9 @@ fn default_timeout() -> u64 {
|
||||
impl Default for MavenConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
proxies: vec!["https://repo1.maven.org/maven2".to_string()],
|
||||
proxies: vec![MavenProxyEntry::Simple(
|
||||
"https://repo1.maven.org/maven2".to_string(),
|
||||
)],
|
||||
proxy_timeout: 30,
|
||||
}
|
||||
}
|
||||
@@ -187,6 +230,7 @@ impl Default for NpmConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
proxy: Some("https://registry.npmjs.org".to_string()),
|
||||
proxy_auth: None,
|
||||
proxy_timeout: 30,
|
||||
}
|
||||
}
|
||||
@@ -196,6 +240,7 @@ impl Default for PypiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
proxy: Some("https://pypi.org/simple/".to_string()),
|
||||
proxy_auth: None,
|
||||
proxy_timeout: 30,
|
||||
}
|
||||
}
|
||||
@@ -309,6 +354,37 @@ impl Default for RateLimitConfig {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Warn if credentials are configured via config.toml (not env vars)
|
||||
pub fn warn_plaintext_credentials(&self) {
|
||||
// Docker upstreams
|
||||
for (i, upstream) in self.docker.upstreams.iter().enumerate() {
|
||||
if upstream.auth.is_some() && std::env::var("NORA_DOCKER_UPSTREAMS").is_err() {
|
||||
tracing::warn!(
|
||||
upstream_index = i,
|
||||
url = %upstream.url,
|
||||
"Docker upstream credentials in config.toml are plaintext — consider NORA_DOCKER_UPSTREAMS env var"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Maven proxies
|
||||
for proxy in &self.maven.proxies {
|
||||
if proxy.auth().is_some() && std::env::var("NORA_MAVEN_PROXIES").is_err() {
|
||||
tracing::warn!(
|
||||
url = %proxy.url(),
|
||||
"Maven proxy credentials in config.toml are plaintext — consider NORA_MAVEN_PROXIES env var"
|
||||
);
|
||||
}
|
||||
}
|
||||
// npm
|
||||
if self.npm.proxy_auth.is_some() && std::env::var("NORA_NPM_PROXY_AUTH").is_err() {
|
||||
tracing::warn!("npm proxy credentials in config.toml are plaintext — consider NORA_NPM_PROXY_AUTH env var");
|
||||
}
|
||||
// PyPI
|
||||
if self.pypi.proxy_auth.is_some() && std::env::var("NORA_PYPI_PROXY_AUTH").is_err() {
|
||||
tracing::warn!("PyPI proxy credentials in config.toml are plaintext — consider NORA_PYPI_PROXY_AUTH env var");
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration with priority: ENV > config.toml > defaults
|
||||
pub fn load() -> Self {
|
||||
// 1. Start with defaults
|
||||
@@ -377,9 +453,23 @@ impl Config {
|
||||
self.auth.htpasswd_file = val;
|
||||
}
|
||||
|
||||
// Maven config
|
||||
// Maven config — supports "url1,url2" or "url1|auth1,url2|auth2"
|
||||
if let Ok(val) = env::var("NORA_MAVEN_PROXIES") {
|
||||
self.maven.proxies = val.split(',').map(|s| s.trim().to_string()).collect();
|
||||
self.maven.proxies = val
|
||||
.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| {
|
||||
let parts: Vec<&str> = s.trim().splitn(2, '|').collect();
|
||||
if parts.len() > 1 {
|
||||
MavenProxyEntry::Full(MavenProxy {
|
||||
url: parts[0].to_string(),
|
||||
auth: Some(parts[1].to_string()),
|
||||
})
|
||||
} else {
|
||||
MavenProxyEntry::Simple(parts[0].to_string())
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
if let Ok(val) = env::var("NORA_MAVEN_PROXY_TIMEOUT") {
|
||||
if let Ok(timeout) = val.parse() {
|
||||
@@ -397,6 +487,11 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// npm proxy auth
|
||||
if let Ok(val) = env::var("NORA_NPM_PROXY_AUTH") {
|
||||
self.npm.proxy_auth = if val.is_empty() { None } else { Some(val) };
|
||||
}
|
||||
|
||||
// PyPI config
|
||||
if let Ok(val) = env::var("NORA_PYPI_PROXY") {
|
||||
self.pypi.proxy = if val.is_empty() { None } else { Some(val) };
|
||||
@@ -407,6 +502,11 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// PyPI proxy auth
|
||||
if let Ok(val) = env::var("NORA_PYPI_PROXY_AUTH") {
|
||||
self.pypi.proxy_auth = if val.is_empty() { None } else { Some(val) };
|
||||
}
|
||||
|
||||
// Docker config
|
||||
if let Ok(val) = env::var("NORA_DOCKER_PROXY_TIMEOUT") {
|
||||
if let Ok(timeout) = val.parse() {
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Serializable snapshot of metrics for persistence
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
struct MetricsSnapshot {
|
||||
downloads: u64,
|
||||
uploads: u64,
|
||||
cache_hits: u64,
|
||||
cache_misses: u64,
|
||||
docker_downloads: u64,
|
||||
docker_uploads: u64,
|
||||
npm_downloads: u64,
|
||||
maven_downloads: u64,
|
||||
maven_uploads: u64,
|
||||
cargo_downloads: u64,
|
||||
pypi_downloads: u64,
|
||||
raw_downloads: u64,
|
||||
raw_uploads: u64,
|
||||
}
|
||||
|
||||
/// Dashboard metrics for tracking registry activity
|
||||
/// Uses atomic counters for thread-safe access without locks
|
||||
@@ -25,6 +46,9 @@ pub struct DashboardMetrics {
|
||||
pub raw_uploads: AtomicU64,
|
||||
|
||||
pub start_time: Instant,
|
||||
|
||||
/// Path to metrics.json for persistence
|
||||
persist_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl DashboardMetrics {
|
||||
@@ -44,6 +68,75 @@ impl DashboardMetrics {
|
||||
raw_downloads: AtomicU64::new(0),
|
||||
raw_uploads: AtomicU64::new(0),
|
||||
start_time: Instant::now(),
|
||||
persist_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create metrics with persistence — loads existing data from metrics.json
|
||||
pub fn with_persistence(storage_path: &str) -> Self {
|
||||
let path = Path::new(storage_path).join("metrics.json");
|
||||
let mut metrics = Self::new();
|
||||
metrics.persist_path = Some(path.clone());
|
||||
|
||||
// Load existing metrics if file exists
|
||||
if path.exists() {
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(data) => match serde_json::from_str::<MetricsSnapshot>(&data) {
|
||||
Ok(snap) => {
|
||||
metrics.downloads = AtomicU64::new(snap.downloads);
|
||||
metrics.uploads = AtomicU64::new(snap.uploads);
|
||||
metrics.cache_hits = AtomicU64::new(snap.cache_hits);
|
||||
metrics.cache_misses = AtomicU64::new(snap.cache_misses);
|
||||
metrics.docker_downloads = AtomicU64::new(snap.docker_downloads);
|
||||
metrics.docker_uploads = AtomicU64::new(snap.docker_uploads);
|
||||
metrics.npm_downloads = AtomicU64::new(snap.npm_downloads);
|
||||
metrics.maven_downloads = AtomicU64::new(snap.maven_downloads);
|
||||
metrics.maven_uploads = AtomicU64::new(snap.maven_uploads);
|
||||
metrics.cargo_downloads = AtomicU64::new(snap.cargo_downloads);
|
||||
metrics.pypi_downloads = AtomicU64::new(snap.pypi_downloads);
|
||||
metrics.raw_downloads = AtomicU64::new(snap.raw_downloads);
|
||||
metrics.raw_uploads = AtomicU64::new(snap.raw_uploads);
|
||||
info!(
|
||||
downloads = snap.downloads,
|
||||
uploads = snap.uploads,
|
||||
"Loaded persisted metrics"
|
||||
);
|
||||
}
|
||||
Err(e) => warn!("Failed to parse metrics.json: {}", e),
|
||||
},
|
||||
Err(e) => warn!("Failed to read metrics.json: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
metrics
|
||||
}
|
||||
|
||||
/// Save current metrics to disk
|
||||
pub fn save(&self) {
|
||||
let Some(path) = &self.persist_path else {
|
||||
return;
|
||||
};
|
||||
let snap = MetricsSnapshot {
|
||||
downloads: self.downloads.load(Ordering::Relaxed),
|
||||
uploads: self.uploads.load(Ordering::Relaxed),
|
||||
cache_hits: self.cache_hits.load(Ordering::Relaxed),
|
||||
cache_misses: self.cache_misses.load(Ordering::Relaxed),
|
||||
docker_downloads: self.docker_downloads.load(Ordering::Relaxed),
|
||||
docker_uploads: self.docker_uploads.load(Ordering::Relaxed),
|
||||
npm_downloads: self.npm_downloads.load(Ordering::Relaxed),
|
||||
maven_downloads: self.maven_downloads.load(Ordering::Relaxed),
|
||||
maven_uploads: self.maven_uploads.load(Ordering::Relaxed),
|
||||
cargo_downloads: self.cargo_downloads.load(Ordering::Relaxed),
|
||||
pypi_downloads: self.pypi_downloads.load(Ordering::Relaxed),
|
||||
raw_downloads: self.raw_downloads.load(Ordering::Relaxed),
|
||||
raw_uploads: self.raw_uploads.load(Ordering::Relaxed),
|
||||
};
|
||||
// Atomic write: write to tmp then rename
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if let Ok(data) = serde_json::to_string_pretty(&snap) {
|
||||
if std::fs::write(&tmp, &data).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#![allow(dead_code)]
|
||||
//! Application error handling with HTTP response conversion
|
||||
//!
|
||||
//! Provides a unified error type that can be converted to HTTP responses
|
||||
@@ -18,6 +17,7 @@ use thiserror::Error;
|
||||
use crate::storage::StorageError;
|
||||
use crate::validation::ValidationError;
|
||||
|
||||
#[allow(dead_code)] // Wiring into handlers planned for v0.3
|
||||
/// Application-level errors with HTTP response conversion
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
@@ -40,6 +40,7 @@ pub enum AppError {
|
||||
Validation(#[from] ValidationError),
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// JSON error response body
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
@@ -74,6 +75,7 @@ impl IntoResponse for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl AppError {
|
||||
/// Create a not found error
|
||||
pub fn not_found(msg: impl Into<String>) -> Self {
|
||||
|
||||
@@ -289,6 +289,9 @@ async fn run_server(config: Config, storage: Storage) {
|
||||
let storage_path = config.storage.path.clone();
|
||||
let rate_limit_enabled = config.rate_limit.enabled;
|
||||
|
||||
// Warn about plaintext credentials in config.toml
|
||||
config.warn_plaintext_credentials();
|
||||
|
||||
// Initialize Docker auth with proxy timeout
|
||||
let docker_auth = registry::DockerAuth::new(config.docker.proxy_timeout);
|
||||
|
||||
@@ -336,7 +339,7 @@ async fn run_server(config: Config, storage: Storage) {
|
||||
start_time,
|
||||
auth,
|
||||
tokens,
|
||||
metrics: DashboardMetrics::new(),
|
||||
metrics: DashboardMetrics::with_persistence(&storage_path),
|
||||
activity: ActivityLog::new(50),
|
||||
audit: AuditLog::new(&storage_path),
|
||||
docker_auth,
|
||||
@@ -387,6 +390,16 @@ async fn run_server(config: Config, storage: Storage) {
|
||||
"Available endpoints"
|
||||
);
|
||||
|
||||
// Background task: persist metrics every 30 seconds
|
||||
let metrics_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
metrics_state.metrics.save();
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown on SIGTERM/SIGINT
|
||||
axum::serve(
|
||||
listener,
|
||||
@@ -396,6 +409,9 @@ async fn run_server(config: Config, storage: Storage) {
|
||||
.await
|
||||
.expect("Server error");
|
||||
|
||||
// Save metrics on shutdown
|
||||
state.metrics.save();
|
||||
|
||||
info!(
|
||||
uptime_seconds = state.start_time.elapsed().as_secs(),
|
||||
"Nora shutdown complete"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! Functions in this module are stubs used only for generating OpenAPI documentation.
|
||||
|
||||
#![allow(dead_code)]
|
||||
#![allow(dead_code)] // utoipa doc stubs — not called at runtime, used by derive macros
|
||||
|
||||
use axum::Router;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::config::basic_auth_header;
|
||||
use crate::registry::docker_auth::DockerAuth;
|
||||
use crate::storage::Storage;
|
||||
use crate::validation::{validate_digest, validate_docker_name, validate_docker_reference};
|
||||
@@ -181,6 +182,7 @@ async fn download_blob(
|
||||
&digest,
|
||||
&state.docker_auth,
|
||||
state.config.docker.proxy_timeout,
|
||||
upstream.auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -392,6 +394,7 @@ async fn get_manifest(
|
||||
&reference,
|
||||
&state.docker_auth,
|
||||
state.config.docker.proxy_timeout,
|
||||
upstream.auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -733,6 +736,7 @@ async fn fetch_blob_from_upstream(
|
||||
digest: &str,
|
||||
docker_auth: &DockerAuth,
|
||||
timeout: u64,
|
||||
basic_auth: Option<&str>,
|
||||
) -> Result<Vec<u8>, ()> {
|
||||
let url = format!(
|
||||
"{}/v2/{}/blobs/{}",
|
||||
@@ -741,13 +745,12 @@ async fn fetch_blob_from_upstream(
|
||||
digest
|
||||
);
|
||||
|
||||
// First try without auth
|
||||
let response = client
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
// First try — with basic auth if configured
|
||||
let mut request = client.get(&url).timeout(Duration::from_secs(timeout));
|
||||
if let Some(credentials) = basic_auth {
|
||||
request = request.header("Authorization", basic_auth_header(credentials));
|
||||
}
|
||||
let response = request.send().await.map_err(|_| ())?;
|
||||
|
||||
let response = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Get Www-Authenticate header and fetch token
|
||||
@@ -758,7 +761,7 @@ async fn fetch_blob_from_upstream(
|
||||
.map(String::from);
|
||||
|
||||
if let Some(token) = docker_auth
|
||||
.get_token(upstream_url, name, www_auth.as_deref())
|
||||
.get_token(upstream_url, name, www_auth.as_deref(), basic_auth)
|
||||
.await
|
||||
{
|
||||
client
|
||||
@@ -790,6 +793,7 @@ async fn fetch_manifest_from_upstream(
|
||||
reference: &str,
|
||||
docker_auth: &DockerAuth,
|
||||
timeout: u64,
|
||||
basic_auth: Option<&str>,
|
||||
) -> Result<(Vec<u8>, String), ()> {
|
||||
let url = format!(
|
||||
"{}/v2/{}/manifests/{}",
|
||||
@@ -806,16 +810,17 @@ async fn fetch_manifest_from_upstream(
|
||||
application/vnd.oci.image.manifest.v1+json, \
|
||||
application/vnd.oci.image.index.v1+json";
|
||||
|
||||
// First try without auth
|
||||
let response = client
|
||||
// First try — with basic auth if configured
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.header("Accept", accept_header)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, url = %url, "Failed to send request to upstream");
|
||||
})?;
|
||||
.header("Accept", accept_header);
|
||||
if let Some(credentials) = basic_auth {
|
||||
request = request.header("Authorization", basic_auth_header(credentials));
|
||||
}
|
||||
let response = request.send().await.map_err(|e| {
|
||||
tracing::error!(error = %e, url = %url, "Failed to send request to upstream");
|
||||
})?;
|
||||
|
||||
tracing::debug!(status = %response.status(), "Initial upstream response");
|
||||
|
||||
@@ -830,7 +835,7 @@ async fn fetch_manifest_from_upstream(
|
||||
tracing::debug!(www_auth = ?www_auth, "Got 401, fetching token");
|
||||
|
||||
if let Some(token) = docker_auth
|
||||
.get_token(upstream_url, name, www_auth.as_deref())
|
||||
.get_token(upstream_url, name, www_auth.as_deref(), basic_auth)
|
||||
.await
|
||||
{
|
||||
tracing::debug!("Token acquired, retrying with auth");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::config::basic_auth_header;
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -36,6 +37,7 @@ impl DockerAuth {
|
||||
registry_url: &str,
|
||||
name: &str,
|
||||
www_authenticate: Option<&str>,
|
||||
basic_auth: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let cache_key = format!("{}:{}", registry_url, name);
|
||||
|
||||
@@ -51,7 +53,7 @@ impl DockerAuth {
|
||||
|
||||
// Need to fetch a new token
|
||||
let www_auth = www_authenticate?;
|
||||
let token = self.fetch_token(www_auth, name).await?;
|
||||
let token = self.fetch_token(www_auth, name, basic_auth).await?;
|
||||
|
||||
// Cache the token (default 5 minute expiry)
|
||||
{
|
||||
@@ -70,7 +72,12 @@ impl DockerAuth {
|
||||
|
||||
/// Parse Www-Authenticate header and fetch token from auth server
|
||||
/// Format: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull"
|
||||
async fn fetch_token(&self, www_authenticate: &str, name: &str) -> Option<String> {
|
||||
async fn fetch_token(
|
||||
&self,
|
||||
www_authenticate: &str,
|
||||
name: &str,
|
||||
basic_auth: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let params = parse_www_authenticate(www_authenticate)?;
|
||||
|
||||
let realm = params.get("realm")?;
|
||||
@@ -82,7 +89,13 @@ impl DockerAuth {
|
||||
|
||||
tracing::debug!(url = %url, "Fetching auth token");
|
||||
|
||||
let response = self.client.get(&url).send().await.ok()?;
|
||||
let mut request = self.client.get(&url);
|
||||
if let Some(credentials) = basic_auth {
|
||||
request = request.header("Authorization", basic_auth_header(credentials));
|
||||
tracing::debug!("Using basic auth for token request");
|
||||
}
|
||||
|
||||
let response = request.send().await.ok()?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
tracing::warn!(status = %response.status(), "Token request failed");
|
||||
@@ -97,44 +110,6 @@ impl DockerAuth {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
}
|
||||
|
||||
/// Make an authenticated request to an upstream registry
|
||||
pub async fn fetch_with_auth(
|
||||
&self,
|
||||
url: &str,
|
||||
registry_url: &str,
|
||||
name: &str,
|
||||
) -> Result<reqwest::Response, ()> {
|
||||
// First try without auth
|
||||
let response = self.client.get(url).send().await.map_err(|_| ())?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
// Extract Www-Authenticate header
|
||||
let www_auth = response
|
||||
.headers()
|
||||
.get("www-authenticate")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from);
|
||||
|
||||
// Get token and retry
|
||||
if let Some(token) = self
|
||||
.get_token(registry_url, name, www_auth.as_deref())
|
||||
.await
|
||||
{
|
||||
return self
|
||||
.client
|
||||
.get(url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ());
|
||||
}
|
||||
|
||||
return Err(());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DockerAuth {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::config::basic_auth_header;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
@@ -49,10 +50,17 @@ async fn download(State(state): State<Arc<AppState>>, Path(path): Path<String>)
|
||||
return with_content_type(&path, data).into_response();
|
||||
}
|
||||
|
||||
for proxy_url in &state.config.maven.proxies {
|
||||
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
|
||||
for proxy in &state.config.maven.proxies {
|
||||
let url = format!("{}/{}", proxy.url().trim_end_matches('/'), path);
|
||||
|
||||
match fetch_from_proxy(&state.http_client, &url, state.config.maven.proxy_timeout).await {
|
||||
match fetch_from_proxy(
|
||||
&state.http_client,
|
||||
&url,
|
||||
state.config.maven.proxy_timeout,
|
||||
proxy.auth(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(data) => {
|
||||
state.metrics.record_download("maven");
|
||||
state.metrics.record_cache_miss();
|
||||
@@ -124,13 +132,13 @@ async fn fetch_from_proxy(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
timeout_secs: u64,
|
||||
auth: Option<&str>,
|
||||
) -> Result<Vec<u8>, ()> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
|
||||
if let Some(credentials) = auth {
|
||||
request = request.header("Authorization", basic_auth_header(credentials));
|
||||
}
|
||||
let response = request.send().await.map_err(|_| ())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::config::basic_auth_header;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
@@ -59,8 +60,13 @@ async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<Str
|
||||
if let Some(proxy_url) = &state.config.npm.proxy {
|
||||
let url = format!("{}/{}", proxy_url.trim_end_matches('/'), path);
|
||||
|
||||
if let Ok(data) =
|
||||
fetch_from_proxy(&state.http_client, &url, state.config.npm.proxy_timeout).await
|
||||
if let Ok(data) = fetch_from_proxy(
|
||||
&state.http_client,
|
||||
&url,
|
||||
state.config.npm.proxy_timeout,
|
||||
state.config.npm.proxy_auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
if is_tarball {
|
||||
state.metrics.record_download("npm");
|
||||
@@ -98,13 +104,13 @@ async fn fetch_from_proxy(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
timeout_secs: u64,
|
||||
auth: Option<&str>,
|
||||
) -> Result<Vec<u8>, ()> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
|
||||
if let Some(credentials) = auth {
|
||||
request = request.header("Authorization", basic_auth_header(credentials));
|
||||
}
|
||||
let response = request.send().await.map_err(|_| ())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use crate::activity_log::{ActionType, ActivityEntry};
|
||||
use crate::audit::AuditEntry;
|
||||
use crate::config::basic_auth_header;
|
||||
use crate::AppState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
@@ -86,8 +87,13 @@ async fn package_versions(
|
||||
if let Some(proxy_url) = &state.config.pypi.proxy {
|
||||
let url = format!("{}/{}/", proxy_url.trim_end_matches('/'), normalized);
|
||||
|
||||
if let Ok(html) =
|
||||
fetch_package_page(&state.http_client, &url, state.config.pypi.proxy_timeout).await
|
||||
if let Ok(html) = fetch_package_page(
|
||||
&state.http_client,
|
||||
&url,
|
||||
state.config.pypi.proxy_timeout,
|
||||
state.config.pypi.proxy_auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Rewrite URLs in the HTML to point to our registry
|
||||
let rewritten = rewrite_pypi_links(&html, &normalized);
|
||||
@@ -140,6 +146,7 @@ async fn download_file(
|
||||
&state.http_client,
|
||||
&page_url,
|
||||
state.config.pypi.proxy_timeout,
|
||||
state.config.pypi.proxy_auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -149,6 +156,7 @@ async fn download_file(
|
||||
&state.http_client,
|
||||
&file_url,
|
||||
state.config.pypi.proxy_timeout,
|
||||
state.config.pypi.proxy_auth.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -202,14 +210,16 @@ async fn fetch_package_page(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
timeout_secs: u64,
|
||||
auth: Option<&str>,
|
||||
) -> Result<String, ()> {
|
||||
let response = client
|
||||
let mut request = client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.header("Accept", "text/html")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
.header("Accept", "text/html");
|
||||
if let Some(credentials) = auth {
|
||||
request = request.header("Authorization", basic_auth_header(credentials));
|
||||
}
|
||||
let response = request.send().await.map_err(|_| ())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
@@ -219,13 +229,17 @@ async fn fetch_package_page(
|
||||
}
|
||||
|
||||
/// Fetch file from upstream
|
||||
async fn fetch_file(client: &reqwest::Client, url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(timeout_secs))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
async fn fetch_file(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
timeout_secs: u64,
|
||||
auth: Option<&str>,
|
||||
) -> Result<Vec<u8>, ()> {
|
||||
let mut request = client.get(url).timeout(Duration::from_secs(timeout_secs));
|
||||
if let Some(credentials) = auth {
|
||||
request = request.header("Authorization", basic_auth_header(credentials));
|
||||
}
|
||||
let response = request.send().await.map_err(|_| ())?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(());
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#![allow(dead_code)] // Foundational code for future S3/Vault integration
|
||||
|
||||
//! Secrets management for NORA
|
||||
//!
|
||||
//! Provides a trait-based architecture for secrets providers:
|
||||
@@ -34,6 +32,7 @@ use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[allow(dead_code)] // Variants used by provider impls; external error handling planned for v0.4
|
||||
/// Secrets provider error
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SecretsError {
|
||||
@@ -56,9 +55,11 @@ pub enum SecretsError {
|
||||
#[async_trait]
|
||||
pub trait SecretsProvider: Send + Sync {
|
||||
/// Get a secret by key (required)
|
||||
#[allow(dead_code)]
|
||||
async fn get_secret(&self, key: &str) -> Result<ProtectedString, SecretsError>;
|
||||
|
||||
/// Get a secret by key (optional, returns None if not found)
|
||||
#[allow(dead_code)]
|
||||
async fn get_secret_optional(&self, key: &str) -> Option<ProtectedString> {
|
||||
self.get_secret(key).await.ok()
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ use zeroize::{Zeroize, Zeroizing};
|
||||
/// - Implements Zeroize: memory is overwritten with zeros when dropped
|
||||
/// - Debug shows `***REDACTED***` instead of actual value
|
||||
/// - Clone creates a new protected copy
|
||||
#[allow(dead_code)] // Used internally by SecretsProvider impls; external callers planned for v0.4
|
||||
#[derive(Clone, Zeroize)]
|
||||
#[zeroize(drop)]
|
||||
pub struct ProtectedString {
|
||||
inner: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ProtectedString {
|
||||
/// Create a new protected string
|
||||
pub fn new(value: String) -> Self {
|
||||
@@ -68,6 +70,7 @@ impl From<&str> for ProtectedString {
|
||||
}
|
||||
|
||||
/// S3 credentials with protected secrets
|
||||
#[allow(dead_code)] // S3 storage backend planned for v0.4
|
||||
#[derive(Clone, Zeroize)]
|
||||
#[zeroize(drop)]
|
||||
pub struct S3Credentials {
|
||||
@@ -77,6 +80,7 @@ pub struct S3Credentials {
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl S3Credentials {
|
||||
pub fn new(access_key_id: String, secret_access_key: String) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -141,11 +141,14 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
|
||||
let pypi_size: u64 = pypi_repos.iter().map(|r| r.size).sum();
|
||||
let total_storage = docker_size + maven_size + npm_size + cargo_size + pypi_size;
|
||||
|
||||
let total_artifacts = docker_repos.len()
|
||||
+ maven_repos.len()
|
||||
+ npm_repos.len()
|
||||
+ cargo_repos.len()
|
||||
+ pypi_repos.len();
|
||||
// Count total versions/tags, not just repositories
|
||||
let docker_versions: usize = docker_repos.iter().map(|r| r.versions).sum();
|
||||
let maven_versions: usize = maven_repos.iter().map(|r| r.versions).sum();
|
||||
let npm_versions: usize = npm_repos.iter().map(|r| r.versions).sum();
|
||||
let cargo_versions: usize = cargo_repos.iter().map(|r| r.versions).sum();
|
||||
let pypi_versions: usize = pypi_repos.iter().map(|r| r.versions).sum();
|
||||
let total_artifacts =
|
||||
docker_versions + maven_versions + npm_versions + cargo_versions + pypi_versions;
|
||||
|
||||
let global_stats = GlobalStats {
|
||||
downloads: state.metrics.downloads.load(Ordering::Relaxed),
|
||||
@@ -158,35 +161,35 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
|
||||
let registry_card_stats = vec![
|
||||
RegistryCardStats {
|
||||
name: "docker".to_string(),
|
||||
artifact_count: docker_repos.len(),
|
||||
artifact_count: docker_versions,
|
||||
downloads: state.metrics.get_registry_downloads("docker"),
|
||||
uploads: state.metrics.get_registry_uploads("docker"),
|
||||
size_bytes: docker_size,
|
||||
},
|
||||
RegistryCardStats {
|
||||
name: "maven".to_string(),
|
||||
artifact_count: maven_repos.len(),
|
||||
artifact_count: maven_versions,
|
||||
downloads: state.metrics.get_registry_downloads("maven"),
|
||||
uploads: state.metrics.get_registry_uploads("maven"),
|
||||
size_bytes: maven_size,
|
||||
},
|
||||
RegistryCardStats {
|
||||
name: "npm".to_string(),
|
||||
artifact_count: npm_repos.len(),
|
||||
artifact_count: npm_versions,
|
||||
downloads: state.metrics.get_registry_downloads("npm"),
|
||||
uploads: 0,
|
||||
size_bytes: npm_size,
|
||||
},
|
||||
RegistryCardStats {
|
||||
name: "cargo".to_string(),
|
||||
artifact_count: cargo_repos.len(),
|
||||
artifact_count: cargo_versions,
|
||||
downloads: state.metrics.get_registry_downloads("cargo"),
|
||||
uploads: 0,
|
||||
size_bytes: cargo_size,
|
||||
},
|
||||
RegistryCardStats {
|
||||
name: "pypi".to_string(),
|
||||
artifact_count: pypi_repos.len(),
|
||||
artifact_count: pypi_versions,
|
||||
downloads: state.metrics.get_registry_downloads("pypi"),
|
||||
uploads: 0,
|
||||
size_bytes: pypi_size,
|
||||
@@ -202,7 +205,12 @@ pub async fn api_dashboard(State(state): State<Arc<AppState>>) -> Json<Dashboard
|
||||
MountPoint {
|
||||
registry: "Maven".to_string(),
|
||||
mount_path: "/maven2/".to_string(),
|
||||
proxy_upstream: state.config.maven.proxies.first().cloned(),
|
||||
proxy_upstream: state
|
||||
.config
|
||||
.maven
|
||||
.proxies
|
||||
.first()
|
||||
.map(|p| p.url().to_string()),
|
||||
},
|
||||
MountPoint {
|
||||
registry: "npm".to_string(),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#![allow(dead_code)]
|
||||
//! Input validation for artifact registry paths and identifiers
|
||||
//!
|
||||
//! Provides security validation to prevent path traversal attacks and
|
||||
@@ -309,63 +308,6 @@ pub fn validate_docker_reference(reference: &str) -> Result<(), ValidationError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate Maven artifact path.
|
||||
///
|
||||
/// Maven paths follow the pattern: groupId/artifactId/version/filename
|
||||
/// Example: `org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar`
|
||||
pub fn validate_maven_path(path: &str) -> Result<(), ValidationError> {
|
||||
validate_storage_key(path)
|
||||
}
|
||||
|
||||
/// Validate npm package name.
|
||||
pub fn validate_npm_name(name: &str) -> Result<(), ValidationError> {
|
||||
if name.is_empty() {
|
||||
return Err(ValidationError::EmptyInput);
|
||||
}
|
||||
|
||||
if name.len() > 214 {
|
||||
return Err(ValidationError::TooLong {
|
||||
max: 214,
|
||||
actual: name.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for path traversal
|
||||
if name.contains("..") {
|
||||
return Err(ValidationError::PathTraversal);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate Cargo crate name.
|
||||
pub fn validate_crate_name(name: &str) -> Result<(), ValidationError> {
|
||||
if name.is_empty() {
|
||||
return Err(ValidationError::EmptyInput);
|
||||
}
|
||||
|
||||
if name.len() > 64 {
|
||||
return Err(ValidationError::TooLong {
|
||||
max: 64,
|
||||
actual: name.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for path traversal
|
||||
if name.contains("..") || name.contains('/') {
|
||||
return Err(ValidationError::PathTraversal);
|
||||
}
|
||||
|
||||
// Crate names: alphanumeric, underscores, hyphens
|
||||
for c in name.chars() {
|
||||
if !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-') {
|
||||
return Err(ValidationError::ForbiddenCharacter(c));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user