Merge pull request #33 from getnora-io/security/scorecard-hardening

security: OpenSSF Scorecard hardening
This commit is contained in:
2026-03-17 13:36:40 +03:00
committed by GitHub
17 changed files with 29630 additions and 42 deletions

View File

@@ -6,18 +6,20 @@ on:
pull_request:
branches: [main]
permissions: read-all
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
- name: Check formatting
run: cargo fmt --check
@@ -33,18 +35,18 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # for uploading SARIF to GitHub Security tab
security-events: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
fetch-depth: 0 # full history required for gitleaks
fetch-depth: 0
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2
# ── Secrets ────────────────────────────────────────────────────────────
- name: Gitleaks — scan for hardcoded secrets
@@ -58,11 +60,11 @@ jobs:
run: cargo install cargo-audit --locked
- name: cargo audit — RustSec advisory database
run: cargo audit --ignore RUSTSEC-2025-0119 # known: number_prefix via indicatif
run: cargo audit --ignore RUSTSEC-2025-0119
# ── Licenses, banned crates, supply chain policy ────────────────────────
- name: cargo deny — licenses and banned crates
uses: EmbarkStudios/cargo-deny-action@v2
uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2
with:
command: check
arguments: --all-features
@@ -70,17 +72,17 @@ jobs:
# ── CVE scan of source tree and Cargo.lock ──────────────────────────────
- name: Trivy — filesystem scan (Cargo.lock + source)
if: always()
uses: aquasecurity/trivy-action@0.35.0
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
with:
scan-type: fs
scan-ref: .
format: sarif
output: trivy-fs.sarif
severity: HIGH,CRITICAL
exit-code: 1 # block pipeline on HIGH/CRITICAL vulnerabilities
exit-code: 1
- name: Upload Trivy fs results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
uses: github/codeql-action/upload-sarif@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
if: always()
with:
sarif_file: trivy-fs.sarif
@@ -92,18 +94,17 @@ jobs:
needs: test
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
- name: Cache cargo
uses: Swatinem/rust-cache@v2
uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # 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 &
@@ -112,7 +113,6 @@ jobs:
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
@@ -133,38 +133,35 @@ jobs:
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
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

View File

@@ -4,6 +4,8 @@ on:
push:
tags: ['v*']
permissions: read-all
env:
REGISTRY: ghcr.io
NORA: localhost:5000
@@ -18,7 +20,7 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Rust
run: |
@@ -32,19 +34,19 @@ jobs:
cp target/x86_64-unknown-linux-musl/release/nora ./nora
- name: Upload binary artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: nora-binary-${{ github.run_id }}
path: ./nora
retention-days: 1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
with:
driver-opts: network=host
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -53,7 +55,7 @@ jobs:
# ── Alpine ───────────────────────────────────────────────────────────────
- name: Extract metadata (alpine)
id: meta-alpine
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with:
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
@@ -64,7 +66,7 @@ jobs:
type=raw,value=latest
- name: Build and push (alpine)
uses: docker/build-push-action@v7
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with:
context: .
file: Dockerfile
@@ -78,7 +80,7 @@ jobs:
# ── RED OS ───────────────────────────────────────────────────────────────
- name: Extract metadata (redos)
id: meta-redos
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with:
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
@@ -90,7 +92,7 @@ jobs:
type=raw,value=redos
- name: Build and push (redos)
uses: docker/build-push-action@v7
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with:
context: .
file: Dockerfile.redos
@@ -104,7 +106,7 @@ jobs:
# ── Astra Linux SE ───────────────────────────────────────────────────────
- name: Extract metadata (astra)
id: meta-astra
uses: docker/metadata-action@v6
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
with:
images: |
${{ env.NORA }}/${{ env.IMAGE_NAME }}
@@ -116,7 +118,7 @@ jobs:
type=raw,value=astra
- name: Build and push (astra)
uses: docker/build-push-action@v7
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
with:
context: .
file: Dockerfile.astra
@@ -165,7 +167,7 @@ jobs:
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Trivy — image scan (${{ matrix.name }})
uses: aquasecurity/trivy-action@0.35.0
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0
with:
scan-type: image
image-ref: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}${{ matrix.suffix }}
@@ -175,7 +177,7 @@ jobs:
exit-code: 1
- name: Upload Trivy image results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v4
uses: github/codeql-action/upload-sarif@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
if: always()
with:
sarif_file: trivy-image-${{ matrix.name }}.sarif
@@ -190,14 +192,14 @@ jobs:
packages: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set version tag (strip leading v)
id: ver
run: echo "tag=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Download binary artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: nora-binary-${{ github.run_id }}
path: ./artifacts
@@ -211,21 +213,21 @@ jobs:
cat nora-linux-amd64.sha256
- name: Generate SBOM (SPDX)
uses: anchore/sbom-action@v0
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0
with:
image: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
format: spdx-json
output-file: nora-${{ github.ref_name }}.sbom.spdx.json
- name: Generate SBOM (CycloneDX)
uses: anchore/sbom-action@v0
uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0
with:
image: ${{ env.NORA }}/${{ env.IMAGE_NAME }}:${{ steps.ver.outputs.tag }}
format: cyclonedx-json
output-file: nora-${{ github.ref_name }}.sbom.cdx.json
- name: Create Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
generate_release_notes: true
files: |

35
.github/workflows/scorecard.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: OpenSSF Scorecard
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1' # every Monday at 06:00 UTC
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
security-events: write
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Run OpenSSF Scorecard
uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2 # v2.4.3
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: Upload Scorecard results to GitHub Security tab
uses: github/codeql-action/upload-sarif@a60c4df7a135c7317c1e9ddf9b5a9b07a910dda9 # v4
with:
sarif_file: results.sarif
category: scorecard

111
dist/install.sh vendored Executable file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env bash
set -euo pipefail
# NORA Artifact Registry — install script
# Usage: curl -fsSL https://getnora.io/install.sh | bash
VERSION="${NORA_VERSION:-latest}"
ARCH=$(uname -m)
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
INSTALL_DIR="/usr/local/bin"
CONFIG_DIR="/etc/nora"
DATA_DIR="/var/lib/nora"
LOG_DIR="/var/log/nora"
case "$ARCH" in
x86_64|amd64) ARCH="x86_64" ;;
aarch64|arm64) ARCH="aarch64" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
echo "Installing NORA ($OS/$ARCH)..."
# Download binary
if [ "$VERSION" = "latest" ]; then
DOWNLOAD_URL="https://github.com/getnora-io/nora/releases/latest/download/nora-${OS}-${ARCH}"
else
DOWNLOAD_URL="https://github.com/getnora-io/nora/releases/download/${VERSION}/nora-${OS}-${ARCH}"
fi
echo "Downloading from $DOWNLOAD_URL..."
if command -v curl &>/dev/null; then
curl -fsSL -o /tmp/nora "$DOWNLOAD_URL"
elif command -v wget &>/dev/null; then
wget -qO /tmp/nora "$DOWNLOAD_URL"
else
echo "Error: curl or wget required"; exit 1
fi
chmod +x /tmp/nora
sudo mv /tmp/nora "$INSTALL_DIR/nora"
# Create system user
if ! id nora &>/dev/null; then
sudo useradd --system --shell /usr/sbin/nologin --home-dir "$DATA_DIR" --create-home nora
echo "Created system user: nora"
fi
# Create directories
sudo mkdir -p "$CONFIG_DIR" "$DATA_DIR" "$LOG_DIR"
sudo chown nora:nora "$DATA_DIR" "$LOG_DIR"
# Install default config if not exists
if [ ! -f "$CONFIG_DIR/nora.env" ]; then
cat > /tmp/nora.env << 'ENVEOF'
NORA_HOST=0.0.0.0
NORA_PORT=4000
NORA_STORAGE_PATH=/var/lib/nora
ENVEOF
sudo mv /tmp/nora.env "$CONFIG_DIR/nora.env"
sudo chmod 600 "$CONFIG_DIR/nora.env"
sudo chown nora:nora "$CONFIG_DIR/nora.env"
echo "Created default config: $CONFIG_DIR/nora.env"
fi
# Install systemd service
if [ -d /etc/systemd/system ]; then
cat > /tmp/nora.service << 'SVCEOF'
[Unit]
Description=NORA Artifact Registry
Documentation=https://getnora.dev
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nora
Group=nora
ExecStart=/usr/local/bin/nora serve
WorkingDirectory=/etc/nora
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/nora /var/log/nora
PrivateTmp=true
EnvironmentFile=-/etc/nora/nora.env
[Install]
WantedBy=multi-user.target
SVCEOF
sudo mv /tmp/nora.service /etc/systemd/system/nora.service
sudo systemctl daemon-reload
sudo systemctl enable nora
echo "Installed systemd service: nora"
fi
echo ""
echo "NORA installed successfully!"
echo ""
echo " Binary: $INSTALL_DIR/nora"
echo " Config: $CONFIG_DIR/nora.env"
echo " Data: $DATA_DIR"
echo " Version: $(nora --version 2>/dev/null || echo 'unknown')"
echo ""
echo "Next steps:"
echo " 1. Edit $CONFIG_DIR/nora.env"
echo " 2. sudo systemctl start nora"
echo " 3. curl http://localhost:4000/health"
echo ""

9031
dist/nora.cdx.json vendored Normal file

File diff suppressed because it is too large Load Diff

31
dist/nora.env.example vendored Normal file
View File

@@ -0,0 +1,31 @@
# NORA configuration — environment variables
# Copy to /etc/nora/nora.env and adjust
# Server
NORA_HOST=0.0.0.0
NORA_PORT=4000
# NORA_PUBLIC_URL=https://registry.example.com
# Storage
NORA_STORAGE_PATH=/var/lib/nora
# NORA_STORAGE_MODE=s3
# NORA_STORAGE_S3_URL=http://minio:9000
# NORA_STORAGE_BUCKET=registry
# Auth (optional)
# NORA_AUTH_ENABLED=true
# NORA_AUTH_HTPASSWD_FILE=/etc/nora/users.htpasswd
# Rate limiting
# NORA_RATE_LIMIT_ENABLED=true
# npm proxy
# NORA_NPM_PROXY=https://registry.npmjs.org
# NORA_NPM_METADATA_TTL=300
# NORA_NPM_PROXY_AUTH=user:pass
# PyPI proxy
# NORA_PYPI_PROXY=https://pypi.org/simple/
# Docker upstreams
# NORA_DOCKER_UPSTREAMS=https://registry-1.docker.io

28
dist/nora.service vendored Normal file
View File

@@ -0,0 +1,28 @@
[Unit]
Description=NORA Artifact Registry
Documentation=https://getnora.dev
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nora
Group=nora
ExecStart=/usr/local/bin/nora serve
WorkingDirectory=/etc/nora
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/nora /var/log/nora
PrivateTmp=true
# Environment
EnvironmentFile=-/etc/nora/nora.env
[Install]
WantedBy=multi-user.target

13
docs-ru/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Документация NORA для Росреестра
## Структура
- `ТУ.md` — Технические условия
- `Руководство.md` — Руководство пользователя
- `Руководство_администратора.md` — Руководство администратора
- `SBOM.md` — Перечень компонентов (Software Bill of Materials)
## Статус
Подготовка документации для включения в Единый реестр российских программ
для электронных вычислительных машин и баз данных (Минцифры РФ).

301
docs-ru/admin-guide.md Normal file
View File

@@ -0,0 +1,301 @@
# Руководство администратора NORA
**Версия:** 1.0
**Дата:** 2026-03-16
**Правообладатель:** ООО «ТАИАРС» (торговая марка АРТАИС)
---
## 1. Общие сведения
NORA — многопротокольный реестр артефактов, предназначенный для хранения, кэширования и распространения программных компонентов. Программа обеспечивает централизованное управление зависимостями при разработке и сборке программного обеспечения.
### 1.1. Назначение
- Хранение и раздача артефактов по протоколам Docker (OCI), npm, Maven, PyPI, Cargo, Helm OCI и Raw.
- Проксирование и кэширование внешних репозиториев для ускорения сборок и обеспечения доступности при отсутствии соединения с сетью Интернет.
- Контроль целостности артефактов посредством верификации SHA-256.
- Аудит и протоколирование всех операций.
### 1.2. Системные требования
| Параметр | Минимальные | Рекомендуемые |
|----------|-------------|---------------|
| ОС | Linux (amd64, arm64) | Ubuntu 22.04+, RHEL 8+ |
| ЦПУ | 1 ядро | 2+ ядра |
| ОЗУ | 256 МБ | 1+ ГБ |
| Диск | 1 ГБ | 50+ ГБ (зависит от объёма хранимых артефактов) |
| Сеть | TCP-порт (по умолчанию 4000) | — |
### 1.3. Зависимости
Программа поставляется как единый статически слинкованный исполняемый файл. Внешние зависимости отсутствуют. Перечень библиотек, включённых в состав программы, приведён в файле `nora.cdx.json` (формат CycloneDX).
---
## 2. Установка
### 2.1. Автоматическая установка
```bash
curl -fsSL https://getnora.io/install.sh | bash
```
Скрипт выполняет следующие действия:
1. Определяет архитектуру процессора (amd64 или arm64).
2. Загружает исполняемый файл с GitHub Releases.
3. Создаёт системного пользователя `nora`.
4. Создаёт каталоги: `/etc/nora/`, `/var/lib/nora/`, `/var/log/nora/`.
5. Устанавливает файл конфигурации `/etc/nora/nora.env`.
6. Устанавливает и активирует systemd-сервис.
### 2.2. Ручная установка
```bash
# Загрузка
wget https://github.com/getnora-io/nora/releases/download/v1.0.0/nora-linux-x86_64
chmod +x nora-linux-x86_64
mv nora-linux-x86_64 /usr/local/bin/nora
# Создание пользователя
useradd --system --shell /usr/sbin/nologin --home-dir /var/lib/nora --create-home nora
# Создание каталогов
mkdir -p /etc/nora /var/lib/nora /var/log/nora
chown nora:nora /var/lib/nora /var/log/nora
# Установка systemd-сервиса
cp dist/nora.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable nora
```
### 2.3. Установка из Docker-образа
```bash
docker run -d \
--name nora \
-p 4000:4000 \
-v nora-data:/data \
ghcr.io/getnora-io/nora:latest
```
---
## 3. Конфигурация
Конфигурация задаётся через переменные окружения, файл `config.toml` или их комбинацию. Приоритет: переменные окружения > config.toml > значения по умолчанию.
### 3.1. Основные параметры
| Переменная | Описание | По умолчанию |
|-----------|----------|--------------|
| `NORA_HOST` | Адрес привязки | `127.0.0.1` |
| `NORA_PORT` | Порт | `4000` |
| `NORA_PUBLIC_URL` | Внешний URL (для генерации ссылок) | — |
| `NORA_STORAGE_PATH` | Путь к каталогу хранилища | `data/storage` |
| `NORA_STORAGE_MODE` | Тип хранилища: `local` или `s3` | `local` |
| `NORA_BODY_LIMIT_MB` | Максимальный размер тела запроса (МБ) | `2048` |
### 3.2. Аутентификация
| Переменная | Описание | По умолчанию |
|-----------|----------|--------------|
| `NORA_AUTH_ENABLED` | Включить аутентификацию | `false` |
| `NORA_AUTH_HTPASSWD_FILE` | Путь к файлу htpasswd | `users.htpasswd` |
Создание пользователя:
```bash
htpasswd -Bc /etc/nora/users.htpasswd admin
```
Роли: `admin` (полный доступ), `write` (чтение и запись), `read` (только чтение, по умолчанию).
### 3.3. Проксирование внешних репозиториев
| Переменная | Описание | По умолчанию |
|-----------|----------|--------------|
| `NORA_NPM_PROXY` | URL npm-реестра | `https://registry.npmjs.org` |
| `NORA_NPM_PROXY_AUTH` | Учётные данные (`user:pass`) | — |
| `NORA_NPM_METADATA_TTL` | TTL кэша метаданных (секунды) | `300` |
| `NORA_PYPI_PROXY` | URL PyPI-реестра | `https://pypi.org/simple/` |
| `NORA_MAVEN_PROXIES` | Список Maven-репозиториев через запятую | `https://repo1.maven.org/maven2` |
| `NORA_DOCKER_UPSTREAMS` | Docker-реестры, формат: `url\|auth,url2` | `https://registry-1.docker.io` |
### 3.4. Ограничение частоты запросов
| Переменная | Описание | По умолчанию |
|-----------|----------|--------------|
| `NORA_RATE_LIMIT_ENABLED` | Включить ограничение | `true` |
| `NORA_RATE_LIMIT_GENERAL_RPS` | Запросов в секунду (общие) | `100` |
| `NORA_RATE_LIMIT_AUTH_RPS` | Запросов в секунду (аутентификация) | `1` |
| `NORA_RATE_LIMIT_UPLOAD_RPS` | Запросов в секунду (загрузка) | `200` |
---
## 4. Управление сервисом
### 4.1. Запуск и остановка
```bash
systemctl start nora # Запуск
systemctl stop nora # Остановка
systemctl restart nora # Перезапуск
systemctl status nora # Статус
journalctl -u nora -f # Просмотр журнала
```
### 4.2. Проверка работоспособности
```bash
curl http://localhost:4000/health
```
Ответ при нормальной работе:
```json
{
"status": "healthy",
"version": "1.0.0",
"storage": { "backend": "local", "reachable": true },
"registries": { "docker": "ok", "npm": "ok", "maven": "ok", "cargo": "ok", "pypi": "ok" }
}
```
### 4.3. Метрики (Prometheus)
```
GET /metrics
```
Экспортируются: количество запросов, латентность, загрузки и выгрузки по протоколам.
---
## 5. Резервное копирование и восстановление
### 5.1. Создание резервной копии
```bash
nora backup --output /backup/nora-$(date +%Y%m%d).tar.gz
```
### 5.2. Восстановление
```bash
nora restore --input /backup/nora-20260316.tar.gz
```
### 5.3. Сборка мусора
```bash
nora gc --dry-run # Просмотр (без удаления)
nora gc # Удаление осиротевших блобов
```
---
## 6. Предварительное кэширование (nora mirror)
Команда `nora mirror` позволяет заранее загрузить зависимости через прокси-кэш NORA. Это обеспечивает доступность артефактов при работе в изолированных средах без доступа к сети Интернет.
### 6.1. Кэширование по lockfile
```bash
nora mirror npm --lockfile package-lock.json --registry http://localhost:4000
nora mirror pip --lockfile requirements.txt --registry http://localhost:4000
nora mirror cargo --lockfile Cargo.lock --registry http://localhost:4000
```
### 6.2. Кэширование по списку пакетов
```bash
nora mirror npm --packages lodash,express --registry http://localhost:4000
nora mirror npm --packages lodash --all-versions --registry http://localhost:4000
```
### 6.3. Параметры
| Флаг | Описание | По умолчанию |
|------|----------|--------------|
| `--registry` | URL экземпляра NORA | `http://localhost:4000` |
| `--concurrency` | Количество параллельных загрузок | `8` |
| `--all-versions` | Загрузить все версии (только с `--packages`) | — |
---
## 7. Миграция хранилища
Перенос артефактов между локальным хранилищем и S3:
```bash
nora migrate --from local --to s3 --dry-run # Просмотр
nora migrate --from local --to s3 # Выполнение
```
---
## 8. Безопасность
### 8.1. Контроль целостности
При проксировании npm-пакетов NORA вычисляет и сохраняет контрольную сумму SHA-256 для каждого тарбола. При повторной выдаче из кэша контрольная сумма проверяется. В случае расхождения запрос отклоняется, а в журнал записывается предупреждение уровня SECURITY.
### 8.2. Защита от подмены пакетов
- Валидация имён файлов при публикации (защита от обхода каталогов).
- Проверка соответствия имени пакета в URL и теле запроса.
- Иммутабельность версий: повторная публикация той же версии запрещена.
### 8.3. Аудит
Все операции (загрузка, выгрузка, обращения к кэшу, ошибки) фиксируются в файле `audit.jsonl` в каталоге хранилища. Формат — JSON Lines, одна запись на строку.
### 8.4. Усиление systemd
Файл сервиса содержит параметры безопасности:
- `NoNewPrivileges=true` — запрет повышения привилегий.
- `ProtectSystem=strict` — файловая система только для чтения, кроме указанных каталогов.
- `ProtectHome=true` — запрет доступа к домашним каталогам.
- `PrivateTmp=true` — изолированный каталог временных файлов.
---
## 9. Точки подключения (endpoints)
| Протокол | Endpoint | Описание |
|----------|----------|----------|
| Docker / OCI | `/v2/` | Docker Registry V2 API |
| npm | `/npm/` | npm-реестр (прокси + публикация) |
| Maven | `/maven2/` | Maven-репозиторий |
| PyPI | `/simple/` | Python Simple API (PEP 503) |
| Cargo | `/cargo/` | Cargo-реестр |
| Helm | `/v2/` (OCI) | Helm-чарты через OCI-протокол |
| Raw | `/raw/` | Произвольные файлы |
| Мониторинг | `/health`, `/ready`, `/metrics` | Проверка и метрики |
| Интерфейс | `/ui/` | Веб-интерфейс управления |
| Документация API | `/api-docs` | OpenAPI (Swagger UI) |
---
## 10. Устранение неполадок
### Сервис не запускается
```bash
journalctl -u nora --no-pager -n 50
```
Частые причины: занят порт, недоступен каталог хранилища, ошибка в конфигурации.
### Прокси-кэш не работает
1. Проверьте доступность внешнего реестра: `curl https://registry.npmjs.org/lodash`.
2. Убедитесь, что переменная `NORA_NPM_PROXY` задана корректно.
3. При использовании приватного реестра укажите `NORA_NPM_PROXY_AUTH`.
### Ошибка целостности (Integrity check failed)
Контрольная сумма кэшированного тарбола не совпадает с сохранённой. Возможные причины: повреждение файловой системы или несанкционированное изменение файла. Удалите повреждённый файл из каталога хранилища — NORA загрузит его заново из внешнего реестра.

165
docs-ru/technical-spec.md Normal file
View File

@@ -0,0 +1,165 @@
# Технические условия
## Программа «NORA — Реестр артефактов»
**Версия документа:** 1.0
**Дата:** 2026-03-16
**Правообладатель:** ООО «ТАИАРС» (торговая марка АРТАИС)
---
## 1. Наименование и обозначение
**Полное наименование:** NORA — многопротокольный реестр артефактов.
**Краткое наименование:** NORA.
**Обозначение:** nora-registry.
---
## 2. Назначение
Программа предназначена для хранения, кэширования и распространения программных компонентов (артефактов), используемых при разработке, сборке и развёртывании программного обеспечения.
### 2.1. Область применения
- Организация внутренних репозиториев программных компонентов.
- Проксирование и кэширование общедоступных репозиториев (npmjs.org, PyPI, Maven Central, Docker Hub, crates.io).
- Обеспечение доступности зависимостей в изолированных средах без доступа к сети Интернет (air-gapped).
- Контроль целостности и безопасности цепочки поставки программного обеспечения.
### 2.2. Класс программного обеспечения
Инструментальное программное обеспечение для разработки и DevOps.
Код ОКПД2: 62.01 — Разработка компьютерного программного обеспечения.
---
## 3. Функциональные характеристики
### 3.1. Поддерживаемые протоколы
| Протокол | Стандарт | Назначение |
|----------|----------|------------|
| Docker / OCI | OCI Distribution Spec v1.0 | Контейнерные образы, Helm-чарты |
| npm | npm Registry API | Библиотеки JavaScript / TypeScript |
| Maven | Maven Repository Layout | Библиотеки Java / Kotlin |
| PyPI | PEP 503 (Simple API) | Библиотеки Python |
| Cargo | Cargo Registry Protocol | Библиотеки Rust |
| Raw | HTTP PUT/GET | Произвольные файлы |
### 3.2. Режимы работы
1. **Хранилище (hosted):** приём и хранение артефактов, опубликованных пользователями.
2. **Прокси-кэш (proxy):** прозрачное проксирование запросов к внешним репозиториям с локальным кэшированием.
3. **Комбинированный:** одновременная работа в режимах хранилища и прокси-кэша (поиск сначала в локальном хранилище, затем во внешнем репозитории).
### 3.3. Управление доступом
- Аутентификация на основе htpasswd (bcrypt).
- Ролевая модель: `read` (чтение), `write` (чтение и запись), `admin` (полный доступ).
- Токены доступа с ограниченным сроком действия.
### 3.4. Безопасность
- Контроль целостности кэшированных артефактов (SHA-256).
- Защита от обхода каталогов (path traversal) при публикации.
- Проверка соответствия имени пакета в URL и теле запроса.
- Иммутабельность опубликованных версий.
- Аудит всех операций в формате JSON Lines.
- Поддержка TLS при размещении за обратным прокси-сервером.
### 3.5. Эксплуатация
- Предварительное кэширование зависимостей (`nora mirror`) по файлам фиксации версий (lockfile).
- Сборка мусора (`nora gc`) — удаление осиротевших блобов.
- Резервное копирование и восстановление (`nora backup`, `nora restore`).
- Миграция между локальным хранилищем и S3-совместимым объектным хранилищем.
- Мониторинг: эндпоинты `/health`, `/ready`, `/metrics` (формат Prometheus).
- Веб-интерфейс для просмотра содержимого реестра.
- Документация API в формате OpenAPI 3.0.
---
## 4. Технические характеристики
### 4.1. Среда исполнения
| Параметр | Значение |
|----------|----------|
| Язык реализации | Rust |
| Формат поставки | Единый исполняемый файл (статическая линковка) |
| Поддерживаемые ОС | Linux (ядро 4.15+) |
| Архитектуры | x86_64 (amd64), aarch64 (arm64) |
| Контейнеризация | Docker-образ на базе `scratch` |
| Системная интеграция | systemd (файл сервиса в комплекте) |
### 4.2. Хранение данных
| Параметр | Значение |
|----------|----------|
| Локальное хранилище | Файловая система (ext4, XFS, ZFS) |
| Объектное хранилище | S3-совместимое API (MinIO, Yandex Object Storage, Selectel S3) |
| Структура | Иерархическая: `{protocol}/{package}/{artifact}` |
| Аудит | Append-only JSONL файл |
### 4.3. Конфигурация
| Источник | Приоритет |
|----------|-----------|
| Переменные окружения (`NORA_*`) | Высший |
| Файл `config.toml` | Средний |
| Значения по умолчанию | Низший |
### 4.4. Производительность
| Параметр | Значение |
|----------|----------|
| Время запуска | < 100 мс |
| Обслуживание из кэша | < 2 мс (метаданные), < 10 мс (артефакты до 1 МБ) |
| Параллельная обработка | Асинхронный ввод-вывод (tokio runtime) |
| Ограничение частоты | Настраиваемое (по умолчанию 100 запросов/сек) |
---
## 5. Лицензирование
| Компонент | Лицензия |
|-----------|----------|
| NORA (core) | MIT License |
| NORA Enterprise | Проприетарная |
Полный перечень лицензий включённых библиотек приведён в файле SBOM (формат CycloneDX).
---
## 6. Комплектность
| Компонент | Описание |
|-----------|----------|
| `nora` | Исполняемый файл |
| `nora.service` | Файл systemd-сервиса |
| `nora.env.example` | Шаблон конфигурации |
| `install.sh` | Скрипт установки |
| `nora.cdx.json` | SBOM в формате CycloneDX |
| Руководство администратора | Настоящий комплект документации |
| Руководство пользователя | Настоящий комплект документации |
| Технические условия | Настоящий документ |
---
## 7. Контактная информация
**Правообладатель:** ООО «ТАИАРС»
**Торговая марка:** АРТАИС
**Сайт продукта:** https://getnora.io
**Документация:** https://getnora.dev
**Исходный код:** https://github.com/getnora-io/nora
**Поддержка:** https://t.me/getnora

221
docs-ru/user-guide.md Normal file
View File

@@ -0,0 +1,221 @@
# Руководство пользователя NORA
**Версия:** 1.0
**Дата:** 2026-03-16
**Правообладатель:** ООО «ТАИАРС» (торговая марка АРТАИС)
---
## 1. Общие сведения
NORA — реестр артефактов для команд разработки. Программа обеспечивает хранение и кэширование библиотек, Docker-образов и иных программных компонентов, используемых при сборке приложений.
Данное руководство предназначено для разработчиков, которые используют NORA в качестве источника зависимостей.
---
## 2. Настройка рабочего окружения
### 2.1. npm / Node.js
Укажите NORA в качестве реестра:
```bash
npm config set registry http://nora.example.com:4000/npm
```
Или создайте файл `.npmrc` в корне проекта:
```
registry=http://nora.example.com:4000/npm
```
После этого все команды `npm install` будут загружать пакеты через NORA. При первом обращении NORA загрузит пакет из внешнего реестра (npmjs.org) и сохранит его в кэш. Последующие обращения обслуживаются из кэша.
### 2.2. Docker
```bash
docker login nora.example.com:4000
docker pull nora.example.com:4000/library/nginx:latest
docker push nora.example.com:4000/myteam/myapp:1.0.0
```
### 2.3. Maven
Добавьте репозиторий в `settings.xml`:
```xml
<mirrors>
<mirror>
<id>nora</id>
<mirrorOf>central</mirrorOf>
<url>http://nora.example.com:4000/maven2</url>
</mirror>
</mirrors>
```
### 2.4. Python / pip
```bash
pip install --index-url http://nora.example.com:4000/simple flask
```
Или в `pip.conf`:
```ini
[global]
index-url = http://nora.example.com:4000/simple
```
### 2.5. Cargo / Rust
Настройка в `~/.cargo/config.toml`:
```toml
[registries.nora]
index = "sparse+http://nora.example.com:4000/cargo/"
[source.crates-io]
replace-with = "nora"
```
### 2.6. Helm
Helm использует OCI-протокол через Docker Registry API:
```bash
helm push mychart-0.1.0.tgz oci://nora.example.com:4000/helm
helm pull oci://nora.example.com:4000/helm/mychart --version 0.1.0
```
---
## 3. Публикация пакетов
### 3.1. npm
```bash
npm publish --registry http://nora.example.com:4000/npm
```
Требования:
- Файл `package.json` с полями `name` и `version`.
- Каждая версия публикуется однократно. Повторная публикация той же версии запрещена.
### 3.2. Docker
```bash
docker tag myapp:latest nora.example.com:4000/myteam/myapp:1.0.0
docker push nora.example.com:4000/myteam/myapp:1.0.0
```
### 3.3. Maven
```bash
mvn deploy -DaltDeploymentRepository=nora::default::http://nora.example.com:4000/maven2
```
### 3.4. Raw (произвольные файлы)
```bash
# Загрузка
curl -X PUT --data-binary @release.tar.gz http://nora.example.com:4000/raw/builds/release-1.0.tar.gz
# Скачивание
curl -O http://nora.example.com:4000/raw/builds/release-1.0.tar.gz
```
---
## 4. Работа в изолированной среде
Если сборочный сервер не имеет доступа к сети Интернет, используйте предварительное кэширование.
### 4.1. Кэширование зависимостей проекта
На машине с доступом к Интернету и NORA выполните:
```bash
nora mirror npm --lockfile package-lock.json --registry http://nora.example.com:4000
```
После этого все зависимости из lockfile будут доступны через NORA, даже если связь с внешними реестрами отсутствует.
### 4.2. Кэширование всех версий пакета
```bash
nora mirror npm --packages lodash,express --all-versions --registry http://nora.example.com:4000
```
Эта команда загрузит все опубликованные версии указанных пакетов.
---
## 5. Веб-интерфейс
NORA предоставляет веб-интерфейс для просмотра содержимого реестра:
```
http://nora.example.com:4000/ui/
```
Доступные функции:
- Просмотр списка артефактов по протоколам.
- Количество версий и размер каждого пакета.
- Журнал последних операций.
- Метрики загрузок.
---
## 6. Документация API
Интерактивная документация API доступна по адресу:
```
http://nora.example.com:4000/api-docs
```
Формат: OpenAPI 3.0 (Swagger UI).
---
## 7. Аутентификация
Если администратор включил аутентификацию, для операций записи требуется токен.
### 7.1. Получение токена
```bash
curl -u admin:password http://nora.example.com:4000/auth/token
```
### 7.2. Использование токена
```bash
# npm
npm config set //nora.example.com:4000/npm/:_authToken TOKEN
# Docker
docker login nora.example.com:4000
# curl
curl -H "Authorization: Bearer TOKEN" http://nora.example.com:4000/npm/my-package
```
Операции чтения по умолчанию не требуют аутентификации (роль `read` назначается автоматически).
---
## 8. Часто задаваемые вопросы
**В: Что произойдёт, если внешний реестр (npmjs.org) станет недоступен?**
О: NORA продолжит обслуживать запросы из кэша. Пакеты, которые ранее не запрашивались, будут недоступны до восстановления связи. Для предотвращения такой ситуации используйте `nora mirror`.
**В: Можно ли публиковать приватные пакеты?**
О: Да. Пакеты, опубликованные через `npm publish` или `docker push`, сохраняются в локальном хранилище NORA и доступны всем пользователям данного экземпляра.
**В: Как обновить кэш метаданных?**
О: Кэш метаданных npm обновляется автоматически по истечении TTL (по умолчанию 5 минут). Для немедленного обновления удалите файл `metadata.json` из каталога хранилища.
**В: Поддерживаются ли scoped-пакеты npm (@scope/package)?**
О: Да, полностью. Например: `npm install @babel/core --registry http://nora.example.com:4000/npm`.

5902
nora-cli/nora-cli.cdx.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ mod gc;
mod health;
mod metrics;
mod migrate;
mod mirror;
mod openapi;
mod rate_limit;
mod registry;
@@ -82,6 +83,17 @@ enum Commands {
#[arg(long, default_value = "false")]
dry_run: bool,
},
/// Pre-fetch dependencies through NORA proxy cache
Mirror {
#[command(subcommand)]
format: mirror::MirrorFormat,
/// NORA registry URL
#[arg(long, default_value = "http://localhost:4000", global = true)]
registry: String,
/// Max concurrent downloads
#[arg(long, default_value = "8", global = true)]
concurrency: usize,
},
}
pub struct AppState {
@@ -164,6 +176,16 @@ async fn main() {
println!("\nRun without --dry-run to delete orphaned blobs.");
}
}
Some(Commands::Mirror {
format,
registry,
concurrency,
}) => {
if let Err(e) = mirror::run_mirror(format, &registry, concurrency).await {
error!("Mirror failed: {}", e);
std::process::exit(1);
}
}
Some(Commands::Migrate { from, to, dry_run }) => {
let source = match from.as_str() {
"local" => Storage::new_local(&config.storage.path),

View File

@@ -0,0 +1,325 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! `nora mirror` — pre-fetch dependencies through NORA proxy cache.
mod npm;
use clap::Subcommand;
use indicatif::{ProgressBar, ProgressStyle};
use std::path::PathBuf;
use std::time::Instant;
#[derive(Subcommand)]
pub enum MirrorFormat {
/// Mirror npm packages
Npm {
/// Path to package-lock.json (v1/v2/v3)
#[arg(long, conflicts_with = "packages")]
lockfile: Option<PathBuf>,
/// Comma-separated package names
#[arg(long, conflicts_with = "lockfile", value_delimiter = ',')]
packages: Option<Vec<String>>,
/// Fetch all versions (only with --packages)
#[arg(long)]
all_versions: bool,
},
/// Mirror Python packages
Pip {
/// Path to requirements.txt
#[arg(long)]
lockfile: PathBuf,
},
/// Mirror Cargo crates
Cargo {
/// Path to Cargo.lock
#[arg(long)]
lockfile: PathBuf,
},
/// Mirror Maven artifacts
Maven {
/// Path to dependency list (mvn dependency:list output)
#[arg(long)]
lockfile: PathBuf,
},
}
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct MirrorTarget {
pub name: String,
pub version: String,
}
pub struct MirrorResult {
pub total: usize,
pub fetched: usize,
pub failed: usize,
pub bytes: u64,
}
pub fn create_progress_bar(total: u64) -> ProgressBar {
let pb = ProgressBar::new(total);
pb.set_style(
ProgressStyle::default_bar()
.template(
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}",
)
.unwrap()
.progress_chars("=>-"),
);
pb
}
pub async fn run_mirror(
format: MirrorFormat,
registry: &str,
concurrency: usize,
) -> Result<(), String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
// Health check
let health_url = format!("{}/health", registry.trim_end_matches('/'));
match client.get(&health_url).send().await {
Ok(r) if r.status().is_success() => {}
_ => {
return Err(format!(
"Cannot connect to NORA at {}. Is `nora serve` running?",
registry
))
}
}
let start = Instant::now();
let result = match format {
MirrorFormat::Npm {
lockfile,
packages,
all_versions,
} => {
npm::run_npm_mirror(
&client,
registry,
lockfile,
packages,
all_versions,
concurrency,
)
.await?
}
MirrorFormat::Pip { lockfile } => {
mirror_lockfile(&client, registry, "pip", &lockfile).await?
}
MirrorFormat::Cargo { lockfile } => {
mirror_lockfile(&client, registry, "cargo", &lockfile).await?
}
MirrorFormat::Maven { lockfile } => {
mirror_lockfile(&client, registry, "maven", &lockfile).await?
}
};
let elapsed = start.elapsed();
println!("\nMirror complete:");
println!(" Total: {}", result.total);
println!(" Fetched: {}", result.fetched);
println!(" Failed: {}", result.failed);
println!(" Size: {:.1} MB", result.bytes as f64 / 1_048_576.0);
println!(" Time: {:.1}s", elapsed.as_secs_f64());
if result.failed > 0 {
Err(format!("{} packages failed to mirror", result.failed))
} else {
Ok(())
}
}
async fn mirror_lockfile(
client: &reqwest::Client,
registry: &str,
format: &str,
lockfile: &PathBuf,
) -> Result<MirrorResult, String> {
let content = std::fs::read_to_string(lockfile)
.map_err(|e| format!("Cannot read {}: {}", lockfile.display(), e))?;
let targets = match format {
"pip" => parse_requirements_txt(&content),
"cargo" => parse_cargo_lock(&content)?,
"maven" => parse_maven_deps(&content),
_ => vec![],
};
if targets.is_empty() {
println!("No packages found in {}", lockfile.display());
return Ok(MirrorResult {
total: 0,
fetched: 0,
failed: 0,
bytes: 0,
});
}
let pb = create_progress_bar(targets.len() as u64);
let base = registry.trim_end_matches('/');
let mut fetched = 0;
let mut failed = 0;
let mut bytes = 0u64;
for target in &targets {
let url = match format {
"pip" => format!("{}/simple/{}/", base, target.name),
"cargo" => format!(
"{}/cargo/api/v1/crates/{}/{}/download",
base, target.name, target.version
),
"maven" => {
let parts: Vec<&str> = target.name.split(':').collect();
if parts.len() == 2 {
let group_path = parts[0].replace('.', "/");
format!(
"{}/maven2/{}/{}/{}/{}-{}.jar",
base, group_path, parts[1], target.version, parts[1], target.version
)
} else {
pb.inc(1);
failed += 1;
continue;
}
}
_ => continue,
};
match client.get(&url).send().await {
Ok(r) if r.status().is_success() => {
if let Ok(body) = r.bytes().await {
bytes += body.len() as u64;
}
fetched += 1;
}
_ => failed += 1,
}
pb.set_message(format!("{}@{}", target.name, target.version));
pb.inc(1);
}
pb.finish_with_message("done");
Ok(MirrorResult {
total: targets.len(),
fetched,
failed,
bytes,
})
}
fn parse_requirements_txt(content: &str) -> Vec<MirrorTarget> {
content
.lines()
.filter(|l| !l.trim().is_empty() && !l.starts_with('#') && !l.starts_with('-'))
.filter_map(|line| {
let line = line.split('#').next().unwrap().trim();
if let Some((name, version)) = line.split_once("==") {
Some(MirrorTarget {
name: name.trim().to_string(),
version: version.trim().to_string(),
})
} else {
let name = line.split(['>', '<', '=', '!', '~', ';']).next()?.trim();
if name.is_empty() {
None
} else {
Some(MirrorTarget {
name: name.to_string(),
version: "latest".to_string(),
})
}
}
})
.collect()
}
fn parse_cargo_lock(content: &str) -> Result<Vec<MirrorTarget>, String> {
let lock: toml::Value =
toml::from_str(content).map_err(|e| format!("Invalid Cargo.lock: {}", e))?;
let packages = lock
.get("package")
.and_then(|p| p.as_array())
.cloned()
.unwrap_or_default();
Ok(packages
.iter()
.filter(|p| {
p.get("source")
.and_then(|s| s.as_str())
.map(|s| s.starts_with("registry+"))
.unwrap_or(false)
})
.filter_map(|p| {
let name = p.get("name")?.as_str()?.to_string();
let version = p.get("version")?.as_str()?.to_string();
Some(MirrorTarget { name, version })
})
.collect())
}
fn parse_maven_deps(content: &str) -> Vec<MirrorTarget> {
content
.lines()
.filter_map(|line| {
let line = line.trim().trim_start_matches("[INFO]").trim();
let parts: Vec<&str> = line.split(':').collect();
if parts.len() >= 4 {
let name = format!("{}:{}", parts[0], parts[1]);
let version = parts[3].to_string();
Some(MirrorTarget { name, version })
} else {
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_requirements_txt() {
let content = "flask==2.3.0\nrequests>=2.28.0\n# comment\nnumpy==1.24.3\n";
let targets = parse_requirements_txt(content);
assert_eq!(targets.len(), 3);
assert_eq!(targets[0].name, "flask");
assert_eq!(targets[0].version, "2.3.0");
assert_eq!(targets[1].name, "requests");
assert_eq!(targets[1].version, "latest");
}
#[test]
fn test_parse_cargo_lock() {
let content = "\
[[package]]
name = \"serde\"
version = \"1.0.197\"
source = \"registry+https://github.com/rust-lang/crates.io-index\"
[[package]]
name = \"my-local-crate\"
version = \"0.1.0\"
";
let targets = parse_cargo_lock(content).unwrap();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].name, "serde");
}
#[test]
fn test_parse_maven_deps() {
let content = "[INFO] org.apache.commons:commons-lang3:jar:3.12.0:compile\n";
let targets = parse_maven_deps(content);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].name, "org.apache.commons:commons-lang3");
assert_eq!(targets[0].version, "3.12.0");
}
}

View File

@@ -0,0 +1,323 @@
// Copyright (c) 2026 Volkov Pavel | DevITWay
// SPDX-License-Identifier: MIT
//! npm lockfile parser + mirror logic.
use super::{create_progress_bar, MirrorResult, MirrorTarget};
use std::collections::HashSet;
use std::path::PathBuf;
use tokio::sync::Semaphore;
/// Entry point for npm mirroring
pub async fn run_npm_mirror(
client: &reqwest::Client,
registry: &str,
lockfile: Option<PathBuf>,
packages: Option<Vec<String>>,
all_versions: bool,
concurrency: usize,
) -> Result<MirrorResult, String> {
let targets = if let Some(path) = lockfile {
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Cannot read {}: {}", path.display(), e))?;
parse_npm_lockfile(&content)?
} else if let Some(names) = packages {
resolve_npm_packages(client, registry, &names, all_versions).await?
} else {
return Err("Specify --lockfile or --packages".to_string());
};
if targets.is_empty() {
println!("No npm packages to mirror");
return Ok(MirrorResult {
total: 0,
fetched: 0,
failed: 0,
bytes: 0,
});
}
println!(
"Mirroring {} npm packages via {}...",
targets.len(),
registry
);
mirror_npm_packages(client, registry, &targets, concurrency).await
}
/// Parse package-lock.json (v1, v2, v3)
fn parse_npm_lockfile(content: &str) -> Result<Vec<MirrorTarget>, String> {
let json: serde_json::Value =
serde_json::from_str(content).map_err(|e| format!("Invalid JSON: {}", e))?;
let version = json
.get("lockfileVersion")
.and_then(|v| v.as_u64())
.unwrap_or(1);
let mut seen = HashSet::new();
let mut targets = Vec::new();
if version >= 2 {
// v2/v3: use "packages" object
if let Some(packages) = json.get("packages").and_then(|p| p.as_object()) {
for (key, pkg) in packages {
if key.is_empty() {
continue; // root package
}
if let Some(name) = extract_package_name(key) {
if let Some(ver) = pkg.get("version").and_then(|v| v.as_str()) {
let pair = (name.to_string(), ver.to_string());
if seen.insert(pair.clone()) {
targets.push(MirrorTarget {
name: pair.0,
version: pair.1,
});
}
}
}
}
}
}
if version == 1 || targets.is_empty() {
// v1 fallback: recursive "dependencies"
if let Some(deps) = json.get("dependencies").and_then(|d| d.as_object()) {
parse_v1_deps(deps, &mut targets, &mut seen);
}
}
Ok(targets)
}
/// Extract package name from lockfile key like "node_modules/@babel/core"
fn extract_package_name(key: &str) -> Option<&str> {
// Handle nested: "node_modules/foo/node_modules/@scope/bar" → "@scope/bar"
let last_nm = key.rfind("node_modules/")?;
let after = &key[last_nm + "node_modules/".len()..];
let name = after.trim_end_matches('/');
if name.is_empty() {
None
} else {
Some(name)
}
}
/// Recursively parse v1 lockfile "dependencies"
fn parse_v1_deps(
deps: &serde_json::Map<String, serde_json::Value>,
targets: &mut Vec<MirrorTarget>,
seen: &mut HashSet<(String, String)>,
) {
for (name, pkg) in deps {
if let Some(ver) = pkg.get("version").and_then(|v| v.as_str()) {
let pair = (name.clone(), ver.to_string());
if seen.insert(pair.clone()) {
targets.push(MirrorTarget {
name: pair.0,
version: pair.1,
});
}
}
// Recurse into nested dependencies
if let Some(nested) = pkg.get("dependencies").and_then(|d| d.as_object()) {
parse_v1_deps(nested, targets, seen);
}
}
}
/// Resolve --packages list by fetching metadata from NORA
async fn resolve_npm_packages(
client: &reqwest::Client,
registry: &str,
names: &[String],
all_versions: bool,
) -> Result<Vec<MirrorTarget>, String> {
let base = registry.trim_end_matches('/');
let mut targets = Vec::new();
for name in names {
let url = format!("{}/npm/{}", base, name);
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
eprintln!("Warning: {} not found (HTTP {})", name, resp.status());
continue;
}
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if all_versions {
if let Some(versions) = json.get("versions").and_then(|v| v.as_object()) {
for ver in versions.keys() {
targets.push(MirrorTarget {
name: name.clone(),
version: ver.clone(),
});
}
}
} else {
// Just latest
let latest = json
.get("dist-tags")
.and_then(|d| d.get("latest"))
.and_then(|v| v.as_str())
.unwrap_or("latest");
targets.push(MirrorTarget {
name: name.clone(),
version: latest.to_string(),
});
}
}
Ok(targets)
}
/// Fetch packages through NORA (triggers proxy cache)
async fn mirror_npm_packages(
client: &reqwest::Client,
registry: &str,
targets: &[MirrorTarget],
concurrency: usize,
) -> Result<MirrorResult, String> {
let base = registry.trim_end_matches('/');
let pb = create_progress_bar(targets.len() as u64);
let sem = std::sync::Arc::new(Semaphore::new(concurrency));
// Deduplicate metadata fetches (one per package name)
let unique_names: HashSet<&str> = targets.iter().map(|t| t.name.as_str()).collect();
pb.set_message("fetching metadata...");
for name in &unique_names {
let url = format!("{}/npm/{}", base, name);
let _ = client.get(&url).send().await; // trigger metadata cache
}
// Fetch tarballs concurrently
let fetched = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let failed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let mut handles = Vec::new();
for target in targets {
let permit = sem.clone().acquire_owned().await.unwrap();
let client = client.clone();
let pb = pb.clone();
let fetched = fetched.clone();
let failed = failed.clone();
let bytes = bytes.clone();
let short_name = target.name.split('/').next_back().unwrap_or(&target.name);
let tarball_url = format!(
"{}/npm/{}/-/{}-{}.tgz",
base, target.name, short_name, target.version
);
let label = format!("{}@{}", target.name, target.version);
handles.push(tokio::spawn(async move {
let _permit = permit;
match client.get(&tarball_url).send().await {
Ok(r) if r.status().is_success() => {
if let Ok(body) = r.bytes().await {
bytes.fetch_add(body.len() as u64, std::sync::atomic::Ordering::Relaxed);
}
fetched.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
_ => {
failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
}
pb.set_message(label);
pb.inc(1);
}));
}
for h in handles {
let _ = h.await;
}
pb.finish_with_message("done");
Ok(MirrorResult {
total: targets.len(),
fetched: fetched.load(std::sync::atomic::Ordering::Relaxed),
failed: failed.load(std::sync::atomic::Ordering::Relaxed),
bytes: bytes.load(std::sync::atomic::Ordering::Relaxed),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_package_name() {
assert_eq!(extract_package_name("node_modules/lodash"), Some("lodash"));
assert_eq!(
extract_package_name("node_modules/@babel/core"),
Some("@babel/core")
);
assert_eq!(
extract_package_name("node_modules/foo/node_modules/bar"),
Some("bar")
);
assert_eq!(
extract_package_name("node_modules/foo/node_modules/@types/node"),
Some("@types/node")
);
assert_eq!(extract_package_name(""), None);
}
#[test]
fn test_parse_lockfile_v3() {
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": { "name": "test" },
"node_modules/lodash": { "version": "4.17.21" },
"node_modules/@babel/core": { "version": "7.26.0" },
"node_modules/@babel/core/node_modules/semver": { "version": "6.3.1" }
}
}"#;
let targets = parse_npm_lockfile(content).unwrap();
assert_eq!(targets.len(), 3);
let names: HashSet<&str> = targets.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains("lodash"));
assert!(names.contains("@babel/core"));
assert!(names.contains("semver"));
}
#[test]
fn test_parse_lockfile_v1() {
let content = r#"{
"lockfileVersion": 1,
"dependencies": {
"express": {
"version": "4.18.2",
"dependencies": {
"accepts": { "version": "1.3.8" }
}
}
}
}"#;
let targets = parse_npm_lockfile(content).unwrap();
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].name, "express");
assert_eq!(targets[1].name, "accepts");
}
#[test]
fn test_deduplication() {
let content = r#"{
"lockfileVersion": 3,
"packages": {
"": {},
"node_modules/debug": { "version": "4.3.4" },
"node_modules/express/node_modules/debug": { "version": "4.3.4" }
}
}"#;
let targets = parse_npm_lockfile(content).unwrap();
assert_eq!(targets.len(), 1); // deduplicated
assert_eq!(targets[0].name, "debug");
}
}

File diff suppressed because it is too large Load Diff