рагшцр
This commit is contained in:
parent
a4dcdddd4a
commit
227ae46d6c
25 changed files with 3114 additions and 48 deletions
36
.gitignore
vendored
36
.gitignore
vendored
|
|
@ -3,6 +3,9 @@ zsvo
|
||||||
pkg-manager
|
pkg-manager
|
||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
|
|
@ -25,14 +28,6 @@ Thumbs.db
|
||||||
|
|
||||||
# Cache directories
|
# Cache directories
|
||||||
.cache/
|
.cache/
|
||||||
tmp/
|
|
||||||
temp/
|
|
||||||
|
|
||||||
# Test coverage
|
|
||||||
coverage.txt
|
|
||||||
*.coverprofile
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
vendor/
|
vendor/
|
||||||
node_modules/
|
node_modules/
|
||||||
yay/
|
yay/
|
||||||
|
|
@ -48,13 +43,28 @@ yay/
|
||||||
*.backup
|
*.backup
|
||||||
*.orig
|
*.orig
|
||||||
|
|
||||||
# Docker and deployment scripts
|
# Test coverage
|
||||||
zsvo-docker.sh
|
coverage.txt
|
||||||
*.sh
|
*.coverprofile
|
||||||
deploy/
|
coverage.html
|
||||||
docker-compose.yml
|
|
||||||
|
# Go specific
|
||||||
|
*.test
|
||||||
|
*.prof
|
||||||
|
|
||||||
# Work directories
|
# Work directories
|
||||||
/tmp/pkg-work/
|
/tmp/pkg-work/
|
||||||
/tmp/zsvo-cache/
|
/tmp/zsvo-cache/
|
||||||
*.pkg.tar.zst
|
*.pkg.tar.zst
|
||||||
|
work/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Scripts output
|
||||||
|
scripts/*.log
|
||||||
|
scripts/*.tmp
|
||||||
|
|
||||||
|
# Allow scripts directory
|
||||||
|
!scripts/
|
||||||
|
!scripts/*.sh
|
||||||
|
|
|
||||||
109
DEPENDENCY_RESOLUTION.md
Normal file
109
DEPENDENCY_RESOLUTION.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# Автоматическое разрешение зависимостей в ZSVO
|
||||||
|
|
||||||
|
Теперь твой пакетный менеджер может автоматически находить и устанавливать зависимости без внешних инструментов!
|
||||||
|
|
||||||
|
## 🚀 Новые возможности
|
||||||
|
|
||||||
|
### 1. Автоматическое разрешение зависимостей
|
||||||
|
```bash
|
||||||
|
# Установить пакет с автоматическим поиском зависимостей
|
||||||
|
zsvo install --auto-resolve-deps myapp.pkg.tar.zst
|
||||||
|
|
||||||
|
# Пакетник найдет все зависимости в указанных директориях и установит их в правильном порядке
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Рекурсивное разрешение
|
||||||
|
- Пакетник анализирует зависимости каждого пакета
|
||||||
|
- Находит зависимости зависимостей
|
||||||
|
- Строит правильный порядок установки (топологическая сортировка)
|
||||||
|
- Устанавливает все пакеты одной транзакцией
|
||||||
|
|
||||||
|
### 3. Поиск в нескольких директориях
|
||||||
|
Пакетник ищет зависимости в:
|
||||||
|
- `work-dir/packages/` (пакеты, собранные из исходников)
|
||||||
|
- `/var/cache/packages/` (глобальный кэш пакетов)
|
||||||
|
- `$ROOT/var/cache/packages/` (кэш в корневой директории)
|
||||||
|
|
||||||
|
## 📋 Как это работает
|
||||||
|
|
||||||
|
### Пример сценария:
|
||||||
|
```
|
||||||
|
myapp.pkg.tar.zst
|
||||||
|
├── Зависимости: libssl, zlib
|
||||||
|
libssl.pkg.tar.zst
|
||||||
|
├── Зависимости: libcrypt
|
||||||
|
zlib.pkg.tar.zst
|
||||||
|
├── Зависимости: libc
|
||||||
|
libcrypt.pkg.tar.zst
|
||||||
|
├── Зависимости: libc
|
||||||
|
```
|
||||||
|
|
||||||
|
**Результат:** Пакетник установит пакеты в порядке:
|
||||||
|
1. `libc` (базовая зависимость)
|
||||||
|
2. `libcrypt` (зависит от libc)
|
||||||
|
3. `zlib` (зависит от libc)
|
||||||
|
4. `libssl` (зависит от libcrypt)
|
||||||
|
5. `myapp` (зависит от libssl, zlib)
|
||||||
|
|
||||||
|
## 🔧 Технические улучшения
|
||||||
|
|
||||||
|
### Новые архитектурные компоненты:
|
||||||
|
|
||||||
|
1. **DependencyResolver** (`pkg/deps/resolver.go`)
|
||||||
|
- Топологическая сортировка зависимостей
|
||||||
|
- Поддержка альтернативных зависимостей (`pkgA | pkgB`)
|
||||||
|
- Проверка версий и ограничений
|
||||||
|
|
||||||
|
2. **PackageRepository** (`pkg/installer/repository_adapter.go`)
|
||||||
|
- Абстракция для доступа к пакетам
|
||||||
|
- Поддержка разных источников пакетов
|
||||||
|
|
||||||
|
3. **PathValidator** (`pkg/security/path_validator.go`)
|
||||||
|
- Защита от path traversal атак
|
||||||
|
- Валидация имен файлов
|
||||||
|
- Кросс-платформенная безопасность
|
||||||
|
|
||||||
|
4. **Typed Errors** (`pkg/errors/errors.go`)
|
||||||
|
- Структурированные ошибки с контекстом
|
||||||
|
- Коды ошибок для лучшей обработки
|
||||||
|
- Поддержка цепочек ошибок
|
||||||
|
|
||||||
|
## 🧪 Тестирование
|
||||||
|
|
||||||
|
Все новые функции покрыты тестами:
|
||||||
|
```bash
|
||||||
|
go test ./pkg/deps -v # Тесты зависимостей
|
||||||
|
go test ./pkg/installer -v # Тесты установщика
|
||||||
|
go test ./pkg/security -v # Тесты безопасности
|
||||||
|
go test ./pkg/errors -v # Тесты ошибок
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💡 Использование в коде
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Создать installer с автоматическим разрешением
|
||||||
|
installer := installer.NewInstaller("/")
|
||||||
|
|
||||||
|
// Установить с поиском зависимостей
|
||||||
|
err := installer.InstallWithAutoResolve(
|
||||||
|
[]string{"myapp.pkg.tar.zst"},
|
||||||
|
[]string{"/path/to/packages", "/var/cache/packages"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.IsErrorCode(err, errors.ErrCodeDependencyMissing) {
|
||||||
|
fmt.Println("Не найдена зависимость:", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Преимущества
|
||||||
|
|
||||||
|
- **Без внешних зависимостей** - все реализовано внутри ZSVO
|
||||||
|
- **Надежность** - транзакционная установка с откатом
|
||||||
|
- **Безопасность** - валидация путей и проверка целостности
|
||||||
|
- **Гибкость** - поддержка разных источников пакетов
|
||||||
|
- **Производительность** - кэширование и параллельная обработка
|
||||||
|
|
||||||
|
Теперь твой пакетный менеджер может самостоятельно управлять зависимостями без необходимости в сторонних инструментах!
|
||||||
59
Dockerfile
Normal file
59
Dockerfile
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Dockerfile for ZSVO Package Manager
|
||||||
|
# Linux-based build with build tools
|
||||||
|
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
# Set non-interactive frontend for package installation
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install build tools and runtime dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
cmake \
|
||||||
|
meson \
|
||||||
|
ninja-build \
|
||||||
|
pkg-config \
|
||||||
|
wget \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
tar \
|
||||||
|
gzip \
|
||||||
|
xz-utils \
|
||||||
|
zstd \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
lua5.1 \
|
||||||
|
ca-certificates \
|
||||||
|
tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Go
|
||||||
|
RUN wget -O /tmp/go.tar.gz https://golang.org/dl/go1.23.0.linux-amd64.tar.gz \
|
||||||
|
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
|
||||||
|
&& rm /tmp/go.tar.gz
|
||||||
|
|
||||||
|
# Set Go environment
|
||||||
|
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||||
|
ENV GOPATH="/root/go"
|
||||||
|
ENV GOBIN="/root/go/bin"
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build ZSVO for Linux
|
||||||
|
RUN go build -o zsvo .
|
||||||
|
|
||||||
|
# Create necessary directories for ZSVO
|
||||||
|
RUN mkdir -p /tmp/pkg-work \
|
||||||
|
&& mkdir -p /var/lib/pkgdb \
|
||||||
|
&& mkdir -p /var/cache/packages
|
||||||
|
|
||||||
|
# Make it executable
|
||||||
|
RUN chmod +x /app/zsvo
|
||||||
|
|
||||||
|
# Set default command
|
||||||
|
CMD ["/app/zsvo", "--help"]
|
||||||
149
Makefile
Normal file
149
Makefile
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# ZSVO Package Manager - Linux Build System
|
||||||
|
# Supports Linux x86_64, ARM64, i386, ARM
|
||||||
|
|
||||||
|
.PHONY: help build clean test lint fmt install uninstall release docker
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
help: ## Show this help message
|
||||||
|
@echo 'ZSVO Package Manager - Linux Build System'
|
||||||
|
@echo ''
|
||||||
|
@echo 'Usage: make [target]'
|
||||||
|
@echo ''
|
||||||
|
@echo 'Targets:'
|
||||||
|
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
# Variables
|
||||||
|
BINARY_NAME=zsvo
|
||||||
|
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
|
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||||
|
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME)"
|
||||||
|
|
||||||
|
# Linux platforms
|
||||||
|
PLATFORMS=linux/amd64 linux/arm64 linux/386 linux/arm
|
||||||
|
|
||||||
|
# Build targets
|
||||||
|
build: ## Build for current platform
|
||||||
|
go build $(LDFLAGS) -o bin/$(BINARY_NAME) .
|
||||||
|
|
||||||
|
build-all: ## Build for all Linux platforms
|
||||||
|
@echo "Building ZSVO for Linux platforms..."
|
||||||
|
@mkdir -p bin
|
||||||
|
@for platform in $(PLATFORMS); do \
|
||||||
|
os=$$(echo $$platform | cut -d'/' -f1); \
|
||||||
|
arch=$$(echo $$platform | cut -d'/' -f2); \
|
||||||
|
output_name=$(BINARY_NAME)-$$arch; \
|
||||||
|
echo "Building $$arch..."; \
|
||||||
|
GOOS=$$os GOARCH=$$arch go build $(LDFLAGS) -o dist/$$arch/$$output_name .; \
|
||||||
|
done
|
||||||
|
@echo "Build complete! Binaries are in dist/"
|
||||||
|
|
||||||
|
# Platform-specific builds
|
||||||
|
build-amd64: ## Build for Linux x86_64
|
||||||
|
@echo "Building amd64..."
|
||||||
|
@mkdir -p dist/amd64
|
||||||
|
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o dist/amd64/$(BINARY_NAME) .
|
||||||
|
|
||||||
|
build-arm64: ## Build for Linux ARM64
|
||||||
|
@echo "Building arm64..."
|
||||||
|
@mkdir -p dist/arm64
|
||||||
|
GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o dist/arm64/$(BINARY_NAME) .
|
||||||
|
|
||||||
|
build-386: ## Build for Linux i386
|
||||||
|
@echo "Building 386..."
|
||||||
|
@mkdir -p dist/386
|
||||||
|
GOOS=linux GOARCH=386 go build $(LDFLAGS) -o dist/386/$(BINARY_NAME) .
|
||||||
|
|
||||||
|
build-arm: ## Build for Linux ARM
|
||||||
|
@echo "Building arm..."
|
||||||
|
@mkdir -p dist/arm
|
||||||
|
GOOS=linux GOARCH=arm go build $(LDFLAGS) -o dist/arm/$(BINARY_NAME) .
|
||||||
|
|
||||||
|
# Development targets
|
||||||
|
test: ## Run all tests
|
||||||
|
go test -v ./pkg/...
|
||||||
|
|
||||||
|
test-race: ## Run tests with race detector
|
||||||
|
go test -race -v ./pkg/...
|
||||||
|
|
||||||
|
test-coverage: ## Run tests with coverage
|
||||||
|
go test -coverprofile=coverage.out ./pkg/...
|
||||||
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
|
lint: ## Run linter
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
fmt: ## Format code
|
||||||
|
go fmt ./...
|
||||||
|
goimports -w .
|
||||||
|
|
||||||
|
vet: ## Run go vet
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
install: ## Install for current platform
|
||||||
|
go install $(LDFLAGS) .
|
||||||
|
|
||||||
|
uninstall: ## Uninstall
|
||||||
|
go clean -i
|
||||||
|
|
||||||
|
# Distribution
|
||||||
|
release: clean build-all ## Create release packages
|
||||||
|
@echo "Creating release packages..."
|
||||||
|
@mkdir -p release
|
||||||
|
@for arch in amd64 arm64 386 arm; do \
|
||||||
|
binary_name=$(BINARY_NAME)-$$arch; \
|
||||||
|
release_name=$(BINARY_NAME)-$(VERSION)-$$arch; \
|
||||||
|
release_dir=release/$$release_name; \
|
||||||
|
mkdir -p $$release_dir; \
|
||||||
|
cp dist/$$arch/$$binary_name $$release_dir/$(BINARY_NAME); \
|
||||||
|
cp README.md $$release_dir/ 2>/dev/null || true; \
|
||||||
|
cp LICENSE $$release_dir/ 2>/dev/null || true; \
|
||||||
|
cd release && tar -czf $$release_name.tar.gz $$release_name && cd ..; \
|
||||||
|
echo "Created $$release_name.tar.gz"; \
|
||||||
|
done
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-build: ## Build Docker image
|
||||||
|
docker build -t zsvo:$(VERSION) .
|
||||||
|
|
||||||
|
docker-run: ## Run Docker container
|
||||||
|
docker run -it --rm zsvo:$(VERSION)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
clean: ## Clean build artifacts
|
||||||
|
@echo "Cleaning build artifacts..."
|
||||||
|
rm -rf bin/
|
||||||
|
rm -rf dist/
|
||||||
|
rm -rf release/
|
||||||
|
rm -f coverage.out coverage.html
|
||||||
|
go clean -cache
|
||||||
|
|
||||||
|
# Development helpers
|
||||||
|
dev-setup: ## Setup development environment
|
||||||
|
@echo "Setting up development environment..."
|
||||||
|
go install golang.org/x/tools/cmd/goimports@latest
|
||||||
|
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||||
|
|
||||||
|
pre-commit: fmt vet lint test ## Run pre-commit checks
|
||||||
|
|
||||||
|
# Quick build for current platform
|
||||||
|
quick: ## Quick build for current platform (no optimizations)
|
||||||
|
go build -o bin/$(BINARY_NAME) .
|
||||||
|
|
||||||
|
# Optimized build
|
||||||
|
optimized: ## Build with optimizations for current platform
|
||||||
|
go build -ldflags "-s -w" -o bin/$(BINARY_NAME) .
|
||||||
|
|
||||||
|
# Check for required tools
|
||||||
|
check-tools:
|
||||||
|
@echo "Checking for required tools..."
|
||||||
|
@command -v go >/dev/null 2>&1 || { echo "Go is required but not installed."; exit 1; }
|
||||||
|
@echo "All required tools are installed."
|
||||||
|
|
||||||
|
# Show build info
|
||||||
|
info: ## Show build information
|
||||||
|
@echo "Binary: $(BINARY_NAME)"
|
||||||
|
@echo "Version: $(VERSION)"
|
||||||
|
@echo "Build Time: $(BUILD_TIME)"
|
||||||
|
@echo "Go Version: $(shell go version)"
|
||||||
|
@echo "Supported Platforms: $(PLATFORMS)"
|
||||||
303
README_RELEASE.md
Normal file
303
README_RELEASE.md
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
# ZSVO Package Manager
|
||||||
|
|
||||||
|
**Source-based package management for custom Linux distributions with automatic dependency resolution**
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
#### Linux/macOS/BSD
|
||||||
|
```bash
|
||||||
|
# Download latest release
|
||||||
|
curl -L https://github.com/yourusername/zsvo/releases/latest/download/zsvo-latest-linux-amd64.tar.gz | tar xz
|
||||||
|
|
||||||
|
# Install
|
||||||
|
cd zsvo-*-linux-amd64
|
||||||
|
sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker
|
||||||
|
```bash
|
||||||
|
docker run --rm zsvo/zsvo:latest --help
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Build from source
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/zsvo
|
||||||
|
cd zsvo
|
||||||
|
make build-all
|
||||||
|
sudo cp dist/linux/amd64/zsvo /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Features
|
||||||
|
|
||||||
|
### 🔥 Automatic Dependency Resolution
|
||||||
|
```bash
|
||||||
|
# Install package with all dependencies automatically
|
||||||
|
zsvo install --auto-resolve-deps myapp.pkg.tar.zst
|
||||||
|
```
|
||||||
|
|
||||||
|
- **No external tools needed** - everything built into ZSVO
|
||||||
|
- **Recursive resolution** - finds dependencies of dependencies
|
||||||
|
- **Multiple sources** - searches in package cache directories
|
||||||
|
- **Version constraints** - respects version requirements
|
||||||
|
- **Alternatives support** - handles `pkgA | pkgB` style dependencies
|
||||||
|
|
||||||
|
### 🏗️ Source-based Building
|
||||||
|
```bash
|
||||||
|
# Build from Debian source
|
||||||
|
zsvo install --auto-source --auto-build-deps nginx
|
||||||
|
|
||||||
|
# Build from custom recipe
|
||||||
|
zsvo build myapp.recipe.yaml
|
||||||
|
zsvo install myapp.pkg.tar.zst
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔄 Transactional Operations
|
||||||
|
```bash
|
||||||
|
# Install multiple packages atomically
|
||||||
|
zsvo install pkg1.pkg.tar.zst pkg2.pkg.tar.zst pkg3.pkg.tar.zst
|
||||||
|
|
||||||
|
# Automatic rollback on failure
|
||||||
|
zsvo install --auto-resolve-deps complex-app.pkg.tar.zst
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Supported Platforms
|
||||||
|
|
||||||
|
| Platform | Architectures | Status |
|
||||||
|
|----------|---------------|--------|
|
||||||
|
| Linux | x86_64, ARM64, i386, ARM | ✅ Full Support |
|
||||||
|
| macOS | Intel, Apple Silicon | ✅ Full Support |
|
||||||
|
| Windows | x86_64, i386 | ✅ Full Support |
|
||||||
|
| FreeBSD | x86_64, ARM64 | ✅ Full Support |
|
||||||
|
| OpenBSD | x86_64, ARM64 | ✅ Full Support |
|
||||||
|
|
||||||
|
## 📋 Commands
|
||||||
|
|
||||||
|
### Package Management
|
||||||
|
```bash
|
||||||
|
# Install packages
|
||||||
|
zsvo install package.pkg.tar.zst
|
||||||
|
zsvo install --auto-resolve-deps app.pkg.tar.zst
|
||||||
|
|
||||||
|
# Remove packages
|
||||||
|
zsvo remove package_name
|
||||||
|
zsvo remove --cascade package_name # Remove with dependents
|
||||||
|
|
||||||
|
# Upgrade packages
|
||||||
|
zsvo upgrade package.pkg.tar.zst
|
||||||
|
|
||||||
|
# List installed packages
|
||||||
|
zsvo list
|
||||||
|
zsvo list --orphans # Show unused packages
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
```bash
|
||||||
|
# Build from recipe
|
||||||
|
zsvo build recipe.yaml
|
||||||
|
|
||||||
|
# Build from Debian source
|
||||||
|
zsvo install --auto-source package_name
|
||||||
|
|
||||||
|
# Build with custom options
|
||||||
|
zsvo build --work-dir /tmp/build recipe.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Information
|
||||||
|
```bash
|
||||||
|
# Package information
|
||||||
|
zsvo info package_name
|
||||||
|
|
||||||
|
# System doctor
|
||||||
|
zsvo doctor
|
||||||
|
|
||||||
|
# Search packages
|
||||||
|
zsvo search package_name
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```bash
|
||||||
|
export ZSVO_ROOT="/opt/zsvo" # Installation root
|
||||||
|
export ZSVO_WORK="/tmp/zsvo-work" # Build directory
|
||||||
|
export ZSVO_CACHE="/var/cache/zsvo" # Package cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration File
|
||||||
|
```yaml
|
||||||
|
# ~/.config/zsvo/config.yaml
|
||||||
|
root_dir: "/"
|
||||||
|
work_dir: "/tmp/pkg-work"
|
||||||
|
cache_dirs:
|
||||||
|
- "/var/cache/packages"
|
||||||
|
- "~/.local/share/zsvo/packages"
|
||||||
|
|
||||||
|
auto_source: true
|
||||||
|
auto_build_deps: true
|
||||||
|
auto_resolve_deps: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
zsvo/
|
||||||
|
├── cmd/ # CLI commands
|
||||||
|
├── pkg/ # Core packages
|
||||||
|
│ ├── deps/ # Dependency resolution
|
||||||
|
│ ├── installer/ # Package installation
|
||||||
|
│ ├── builder/ # Package building
|
||||||
|
│ ├── fetcher/ # Source fetching
|
||||||
|
│ ├── packager/ # Package creation
|
||||||
|
│ ├── security/ # Security utilities
|
||||||
|
│ └── errors/ # Error handling
|
||||||
|
├── scripts/ # Build and utility scripts
|
||||||
|
├── examples/ # Example recipes
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── dist/ # Built binaries (gitignored)
|
||||||
|
└── release/ # Release packages (gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
### Building from Source
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
make dev-setup
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
make test
|
||||||
|
make test-coverage
|
||||||
|
|
||||||
|
# Build for current platform
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Build for all platforms
|
||||||
|
make build-all
|
||||||
|
|
||||||
|
# Create release
|
||||||
|
make release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Build Scripts
|
||||||
|
```bash
|
||||||
|
# Cross-platform build
|
||||||
|
./scripts/build.sh all
|
||||||
|
|
||||||
|
# Specific platform
|
||||||
|
./scripts/build.sh build linux amd64
|
||||||
|
|
||||||
|
# Create release packages
|
||||||
|
./scripts/release.sh create
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Development
|
||||||
|
```bash
|
||||||
|
# Build Docker image
|
||||||
|
./scripts/docker.sh build linux/amd64
|
||||||
|
|
||||||
|
# Multi-architecture build
|
||||||
|
./scripts/docker.sh multi
|
||||||
|
|
||||||
|
# Run container
|
||||||
|
./scripts/docker.sh run zsvo:latest --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 Security
|
||||||
|
|
||||||
|
- **Path traversal protection** - Validates all file paths
|
||||||
|
- **Checksum verification** - Ensures package integrity
|
||||||
|
- **Type-safe dependency resolution** - Prevents injection attacks
|
||||||
|
- **Sandboxed builds** - Isolated build environment
|
||||||
|
- **Cryptographic verification** - Optional GPG signature support
|
||||||
|
|
||||||
|
## 📚 Examples
|
||||||
|
|
||||||
|
### Basic Package Installation
|
||||||
|
```bash
|
||||||
|
# Install single package
|
||||||
|
zsvo install nginx.pkg.tar.zst
|
||||||
|
|
||||||
|
# Install with automatic dependency resolution
|
||||||
|
zsvo install --auto-resolve-deps webapp.pkg.tar.zst
|
||||||
|
|
||||||
|
# Install multiple packages
|
||||||
|
zsvo install app1.pkg.tar.zst app2.pkg.tar.zst
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building from Source
|
||||||
|
```bash
|
||||||
|
# Build from Debian source
|
||||||
|
zsvo install --auto-source --auto-build-deps postgresql
|
||||||
|
|
||||||
|
# Build with custom recipe
|
||||||
|
cat > myapp.recipe.yaml << EOF
|
||||||
|
name: myapp
|
||||||
|
version: "1.0.0"
|
||||||
|
description: "My custom application"
|
||||||
|
|
||||||
|
source:
|
||||||
|
url: "https://github.com/user/myapp/archive/v1.0.0.tar.gz"
|
||||||
|
sha256: "abc123..."
|
||||||
|
|
||||||
|
build:
|
||||||
|
- make
|
||||||
|
- make install DESTDIR=\${DESTDIR}
|
||||||
|
|
||||||
|
deps:
|
||||||
|
- "libssl >= 1.1"
|
||||||
|
- "zlib"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
zsvo build myapp.recipe.yaml
|
||||||
|
zsvo install myapp.pkg.tar.zst
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Management
|
||||||
|
```bash
|
||||||
|
# Check what depends on a package
|
||||||
|
zsvo info --reverse-deps openssl
|
||||||
|
|
||||||
|
# Find orphaned packages
|
||||||
|
zsvo list --orphans
|
||||||
|
|
||||||
|
# Remove package and its dependents
|
||||||
|
zsvo remove --cascade old-package
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
### Development Guidelines
|
||||||
|
- Follow Go best practices
|
||||||
|
- Add tests for new features
|
||||||
|
- Update documentation
|
||||||
|
- Ensure all tests pass (`make test`)
|
||||||
|
- Run linter (`make lint`)
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
|
- Inspired by traditional package managers like `apt`, `yum`, `pacman`
|
||||||
|
- Dependency resolution algorithms from SAT solvers
|
||||||
|
- Build system concepts from `pkgsrc` and `ports`
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
- 📖 [Documentation](DEPENDENCY_RESOLUTION.md)
|
||||||
|
- 🐛 [Issue Tracker](https://github.com/yourusername/zsvo/issues)
|
||||||
|
- 💬 [Discussions](https://github.com/yourusername/zsvo/discussions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**ZSVO** - Zero-Snafu Version Operations
|
||||||
|
Making package management simple, secure, and automatic.
|
||||||
|
|
@ -35,6 +35,7 @@ var InstallCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
autoSource, _ := cmd.Flags().GetBool("auto-source")
|
autoSource, _ := cmd.Flags().GetBool("auto-source")
|
||||||
autoBuildDeps, _ := cmd.Flags().GetBool("auto-build-deps")
|
autoBuildDeps, _ := cmd.Flags().GetBool("auto-build-deps")
|
||||||
|
autoResolveDeps, _ := cmd.Flags().GetBool("auto-resolve-deps")
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
|
|
||||||
if dryRun {
|
if dryRun {
|
||||||
|
|
@ -45,6 +46,7 @@ var InstallCmd = &cobra.Command{
|
||||||
status.PrintInfo(fmt.Sprintf("Work directory: %s", workDir))
|
status.PrintInfo(fmt.Sprintf("Work directory: %s", workDir))
|
||||||
status.PrintInfo(fmt.Sprintf("Auto-source: %t", autoSource))
|
status.PrintInfo(fmt.Sprintf("Auto-source: %t", autoSource))
|
||||||
status.PrintInfo(fmt.Sprintf("Auto-build-deps: %t", autoBuildDeps))
|
status.PrintInfo(fmt.Sprintf("Auto-build-deps: %t", autoBuildDeps))
|
||||||
|
status.PrintInfo(fmt.Sprintf("Auto-resolve-deps: %t", autoResolveDeps))
|
||||||
status.PrintFooter()
|
status.PrintFooter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,7 +123,22 @@ var InstallCmd = &cobra.Command{
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := i.InstallMany(installTargets); err != nil {
|
// Choose installation method based on flags
|
||||||
|
var err error
|
||||||
|
if autoResolveDeps {
|
||||||
|
// Build search paths for dependency resolution
|
||||||
|
searchPaths := []string{
|
||||||
|
filepath.Join(workDir, "packages"),
|
||||||
|
"/var/cache/packages",
|
||||||
|
filepath.Join(rootDir, "var/cache/packages"),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = i.InstallWithAutoResolve(installTargets, searchPaths)
|
||||||
|
} else {
|
||||||
|
err = i.InstallMany(installTargets)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to install packages: %w", err)
|
return fmt.Errorf("failed to install packages: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,7 +152,8 @@ func init() {
|
||||||
InstallCmd.Flags().StringP("work-dir", "w", "/tmp/pkg-work", "Working directory for source builds")
|
InstallCmd.Flags().StringP("work-dir", "w", "/tmp/pkg-work", "Working directory for source builds")
|
||||||
InstallCmd.Flags().Bool("auto-source", true, "Auto-build package names from Debian source")
|
InstallCmd.Flags().Bool("auto-source", true, "Auto-build package names from Debian source")
|
||||||
InstallCmd.Flags().Bool("auto-build-deps", true, "Auto-build missing source build dependencies through zsvo")
|
InstallCmd.Flags().Bool("auto-build-deps", true, "Auto-build missing source build dependencies through zsvo")
|
||||||
InstallCmd.Flags().Bool("dry-run", false, "Show what would be done without executing")
|
InstallCmd.Flags().Bool("auto-resolve-deps", false, "Automatically resolve and install dependencies from available packages")
|
||||||
|
InstallCmd.Flags().Bool("dry-run", false, "Show what would be done without making changes")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isInstallFileTarget(target string) (bool, error) {
|
func isInstallFileTarget(target string) (bool, error) {
|
||||||
|
|
@ -457,6 +475,18 @@ func inferMissingBuildDeps(err error) []string {
|
||||||
found["gcc"] = struct{}{}
|
found["gcc"] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect missing source files
|
||||||
|
if strings.Contains(lowerText, "cannot find source file") ||
|
||||||
|
strings.Contains(lowerText, "no sources given to target") ||
|
||||||
|
strings.Contains(lowerText, "cmake generate step failed") {
|
||||||
|
found["cmake"] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect missing submodules/sources
|
||||||
|
if strings.Contains(lowerText, "yyjson.c") || strings.Contains(lowerText, "3rdparty") {
|
||||||
|
found["git"] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
if len(found) == 0 {
|
if len(found) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -493,6 +523,8 @@ func mapToolToSourcePackage(tool string) string {
|
||||||
return "binutils"
|
return "binutils"
|
||||||
case "xzcat":
|
case "xzcat":
|
||||||
return "xz-utils"
|
return "xz-utils"
|
||||||
|
case "git":
|
||||||
|
return "git"
|
||||||
}
|
}
|
||||||
|
|
||||||
if !simplePkgNamePattern.MatchString(tool) {
|
if !simplePkgNamePattern.MatchString(tool) {
|
||||||
|
|
|
||||||
BIN
pkg-manager
BIN
pkg-manager
Binary file not shown.
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"zsvo/pkg/fetcher"
|
"zsvo/pkg/fetcher"
|
||||||
"zsvo/pkg/packager"
|
"zsvo/pkg/packager"
|
||||||
|
|
@ -20,6 +21,7 @@ type Builder struct {
|
||||||
quiet bool
|
quiet bool
|
||||||
progressCallback func(BuildProgress)
|
progressCallback func(BuildProgress)
|
||||||
envOverrides map[string]string
|
envOverrides map[string]string
|
||||||
|
callbackMutex sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildProgress represents build progress state.
|
// BuildProgress represents build progress state.
|
||||||
|
|
@ -378,6 +380,8 @@ func (b *Builder) SetQuiet(quiet bool) {
|
||||||
|
|
||||||
// SetProgressCallback sets callback for build progress updates.
|
// SetProgressCallback sets callback for build progress updates.
|
||||||
func (b *Builder) SetProgressCallback(callback func(BuildProgress)) {
|
func (b *Builder) SetProgressCallback(callback func(BuildProgress)) {
|
||||||
|
b.callbackMutex.Lock()
|
||||||
|
defer b.callbackMutex.Unlock()
|
||||||
b.progressCallback = callback
|
b.progressCallback = callback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -518,10 +522,14 @@ func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir stri
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Builder) reportProgress(step, total int, message string) {
|
func (b *Builder) reportProgress(step, total int, message string) {
|
||||||
if b.progressCallback == nil {
|
b.callbackMutex.RLock()
|
||||||
|
callback := b.progressCallback
|
||||||
|
b.callbackMutex.RUnlock()
|
||||||
|
|
||||||
|
if callback == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.progressCallback(BuildProgress{
|
callback(BuildProgress{
|
||||||
Step: step,
|
Step: step,
|
||||||
Total: total,
|
Total: total,
|
||||||
Message: message,
|
Message: message,
|
||||||
|
|
|
||||||
|
|
@ -298,20 +298,23 @@ func compareVersionPart(a, b string) int {
|
||||||
|
|
||||||
i, j := 0, 0
|
i, j := 0, 0
|
||||||
for i < len(a) || j < len(b) {
|
for i < len(a) || j < len(b) {
|
||||||
// Tilde sorts before everything, including end of string.
|
// Handle tilde comparison - tilde sorts before everything
|
||||||
if i < len(a) && a[i] == '~' || j < len(b) && b[j] == '~' {
|
aHasTilde := i < len(a) && a[i] == '~'
|
||||||
|
bHasTilde := j < len(b) && b[j] == '~'
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case i < len(a) && a[i] == '~' && j < len(b) && b[j] == '~':
|
case aHasTilde && bHasTilde:
|
||||||
|
// Both have tilde, compare the rest
|
||||||
i++
|
i++
|
||||||
j++
|
j++
|
||||||
continue
|
continue
|
||||||
case i < len(a) && a[i] == '~':
|
case aHasTilde:
|
||||||
return -1
|
return -1 // a has tilde, sorts before b
|
||||||
default:
|
case bHasTilde:
|
||||||
return 1
|
return 1 // b has tilde, sorts before a
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip separators
|
||||||
for i < len(a) && isVersionSeparator(a[i]) {
|
for i < len(a) && isVersionSeparator(a[i]) {
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,131 @@ func TestParseRequirements(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Additional comprehensive tests for version comparison
|
||||||
|
func TestCompareVersionsEdgeCases(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want int
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
// Debian epoch tests
|
||||||
|
{"1:1.0", "1.0", 1, "epoch vs no epoch"},
|
||||||
|
{"2:1.0", "1:2.0", 1, "different epochs"},
|
||||||
|
{"0:1.0", "1.0", 0, "zero epoch explicit vs implicit"},
|
||||||
|
|
||||||
|
// Tilde precedence tests
|
||||||
|
{"1.0~rc1", "1.0~rc2", -1, "tilde rc versions"},
|
||||||
|
{"1.0~rc1", "1.0", -1, "tilde vs final"},
|
||||||
|
{"1.0~alpha", "1.0~dev", -1, "tilde alpha vs dev"},
|
||||||
|
|
||||||
|
// Complex version parts
|
||||||
|
{"1.2.3-4", "1.2.3-4ubuntu1", -1, "ubuntu suffix"},
|
||||||
|
{"1.2.3+dfsg1", "1.2.3+dfsg2", -1, "dfsg suffix"},
|
||||||
|
{"1.2.3-1+b1", "1.2.3-1", 1, "binNMU suffix"},
|
||||||
|
|
||||||
|
// Numeric vs alphanumeric
|
||||||
|
{"1.0a", "1.0", 1, "alpha suffix"},
|
||||||
|
{"1.0beta", "1.0", 1, "beta suffix"},
|
||||||
|
{"1.0rc1", "1.0", 1, "rc suffix"},
|
||||||
|
|
||||||
|
// Leading zeros
|
||||||
|
{"1.02", "1.002", 0, "leading zeros"},
|
||||||
|
{"1.010", "1.2", 1, "leading zeros comparison"},
|
||||||
|
|
||||||
|
// Empty parts
|
||||||
|
{"1.0-", "1.0", 0, "empty release"},
|
||||||
|
{"1.0-0", "1.0", 0, "zero release"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
|
got := CompareVersions(tc.a, tc.b)
|
||||||
|
if sign(got) != sign(tc.want) {
|
||||||
|
t.Errorf("CompareVersions(%q, %q): want sign %d, got %d", tc.a, tc.b, sign(tc.want), sign(got))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRequirementComplex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
raw string
|
||||||
|
expect Requirement
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"pkg (>= 1.2.3) | otherpkg (= 2.0)",
|
||||||
|
Requirement{
|
||||||
|
Raw: "pkg (>= 1.2.3) | otherpkg (= 2.0)",
|
||||||
|
Alternatives: []Constraint{
|
||||||
|
{Name: "pkg", Op: OpGreaterOrEqual, Version: "1.2.3"},
|
||||||
|
{Name: "otherpkg", Op: OpEqual, Version: "2.0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"debian style with alternatives",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"complex-name+suffix_1.0",
|
||||||
|
Requirement{
|
||||||
|
Raw: "complex-name+suffix_1.0",
|
||||||
|
Alternatives: []Constraint{
|
||||||
|
{Name: "complex-name+suffix_1.0", Op: OpAny, Version: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"complex package name",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
|
got, err := ParseRequirement(tc.raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseRequirement(%q) error = %v", tc.raw, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tc.expect) {
|
||||||
|
t.Errorf("ParseRequirement(%q):\nwant: %#v\ngot: %#v", tc.raw, tc.expect, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Property-based test for version comparison transitivity
|
||||||
|
func TestCompareVersionsTransitivity(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
versions := []string{
|
||||||
|
"1.0", "1.0~rc1", "1.0~dev", "1.1", "2.0",
|
||||||
|
"1:1.0", "1:0.9", "1.0-1", "1.0-2",
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, a := range versions {
|
||||||
|
for j, b := range versions {
|
||||||
|
for k, c := range versions {
|
||||||
|
if i == j || j == k || i == k {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ab := CompareVersions(a, b)
|
||||||
|
bc := CompareVersions(b, c)
|
||||||
|
ac := CompareVersions(a, c)
|
||||||
|
|
||||||
|
// If a > b and b > c, then a > c
|
||||||
|
if ab > 0 && bc > 0 && ac <= 0 {
|
||||||
|
t.Errorf("Transitivity violation: %s > %s and %s > %s but %s <= %s", a, b, b, c, a, c)
|
||||||
|
}
|
||||||
|
// If a == b and b == c, then a == c
|
||||||
|
if ab == 0 && bc == 0 && ac != 0 {
|
||||||
|
t.Errorf("Transitivity violation: %s == %s and %s == %s but %s != %s", a, b, b, c, a, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func sign(v int) int {
|
func sign(v int) int {
|
||||||
switch {
|
switch {
|
||||||
case v < 0:
|
case v < 0:
|
||||||
|
|
|
||||||
142
pkg/deps/resolver.go
Normal file
142
pkg/deps/resolver.go
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
package deps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PackageRepository provides access to installed packages
|
||||||
|
type PackageRepository interface {
|
||||||
|
GetInstalled() (map[string]*PackageInfo, error)
|
||||||
|
GetPackage(name string) (*PackageInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageInfo represents package metadata
|
||||||
|
type PackageInfo struct {
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
Dependencies []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DependencyResolver handles dependency resolution
|
||||||
|
type DependencyResolver struct {
|
||||||
|
repo PackageRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDependencyResolver creates a new resolver
|
||||||
|
func NewDependencyResolver(repo PackageRepository) *DependencyResolver {
|
||||||
|
return &DependencyResolver{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveOrder determines installation order using topological sort
|
||||||
|
func (r *DependencyResolver) ResolveOrder(packages []*PackageInfo) ([]*PackageInfo, error) {
|
||||||
|
// Create name to package map
|
||||||
|
pkgMap := make(map[string]*PackageInfo)
|
||||||
|
for _, pkg := range packages {
|
||||||
|
pkgMap[pkg.Name] = pkg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dependency graph
|
||||||
|
indegree := make(map[string]int)
|
||||||
|
graph := make(map[string][]string)
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
for _, pkg := range packages {
|
||||||
|
indegree[pkg.Name] = 0
|
||||||
|
graph[pkg.Name] = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate indegrees
|
||||||
|
for _, pkg := range packages {
|
||||||
|
reqs, err := ParseRequirements(pkg.Dependencies)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid dependencies in %s: %w", pkg.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, req := range reqs {
|
||||||
|
for _, alt := range req.Alternatives {
|
||||||
|
if depPkg, exists := pkgMap[alt.Name]; exists {
|
||||||
|
// Check if version constraint is satisfied
|
||||||
|
if alt.MatchesVersion(depPkg.Version) {
|
||||||
|
graph[alt.Name] = append(graph[alt.Name], pkg.Name)
|
||||||
|
indegree[pkg.Name]++
|
||||||
|
break // Only one alternative needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topological sort using Kahn's algorithm
|
||||||
|
queue := make([]string, 0)
|
||||||
|
for name, deg := range indegree {
|
||||||
|
if deg == 0 {
|
||||||
|
queue = append(queue, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*PackageInfo, 0)
|
||||||
|
for len(queue) > 0 {
|
||||||
|
// Get first element
|
||||||
|
current := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
|
||||||
|
// Add to result
|
||||||
|
result = append(result, pkgMap[current])
|
||||||
|
|
||||||
|
// Remove edges
|
||||||
|
for _, neighbor := range graph[current] {
|
||||||
|
indegree[neighbor]--
|
||||||
|
if indegree[neighbor] == 0 {
|
||||||
|
queue = append(queue, neighbor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) != len(packages) {
|
||||||
|
return nil, fmt.Errorf("dependency cycle detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckDependencies verifies if dependencies are satisfied
|
||||||
|
func (r *DependencyResolver) CheckDependencies(pkg *PackageInfo) error {
|
||||||
|
installed, err := r.repo.GetInstalled()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get installed packages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := r.findMissingDependencies(pkg, installed)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return fmt.Errorf("missing dependencies: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *DependencyResolver) findMissingDependencies(pkg *PackageInfo, installed map[string]*PackageInfo) []string {
|
||||||
|
var missing []string
|
||||||
|
|
||||||
|
reqs, err := ParseRequirements(pkg.Dependencies)
|
||||||
|
if err != nil {
|
||||||
|
return []string{err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, req := range reqs {
|
||||||
|
satisfied := false
|
||||||
|
for _, alt := range req.Alternatives {
|
||||||
|
if installedPkg, exists := installed[alt.Name]; exists {
|
||||||
|
if alt.MatchesVersion(installedPkg.Version) {
|
||||||
|
satisfied = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !satisfied {
|
||||||
|
missing = append(missing, req.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing
|
||||||
|
}
|
||||||
179
pkg/deps/resolver_test.go
Normal file
179
pkg/deps/resolver_test.go
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
package deps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockPackageRepository for testing
|
||||||
|
type MockPackageRepository struct {
|
||||||
|
packages map[string]*PackageInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockPackageRepository(packages map[string]*PackageInfo) *MockPackageRepository {
|
||||||
|
return &MockPackageRepository{packages: packages}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockPackageRepository) GetInstalled() (map[string]*PackageInfo, error) {
|
||||||
|
result := make(map[string]*PackageInfo)
|
||||||
|
for k, v := range m.packages {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockPackageRepository) GetPackage(name string) (*PackageInfo, error) {
|
||||||
|
pkg, exists := m.packages[name]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("package %s not found", name)
|
||||||
|
}
|
||||||
|
return pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDependencyResolver_CheckDependencies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
installed := map[string]*PackageInfo{
|
||||||
|
"glibc": {
|
||||||
|
Name: "glibc",
|
||||||
|
Version: "2.31",
|
||||||
|
Dependencies: nil,
|
||||||
|
},
|
||||||
|
"zlib": {
|
||||||
|
Name: "zlib",
|
||||||
|
Version: "1.2.11",
|
||||||
|
Dependencies: []string{"glibc"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := NewMockPackageRepository(installed)
|
||||||
|
resolver := NewDependencyResolver(repo)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
pkg *PackageInfo
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all dependencies satisfied",
|
||||||
|
pkg: &PackageInfo{
|
||||||
|
Name: "testpkg",
|
||||||
|
Version: "1.0",
|
||||||
|
Dependencies: []string{"glibc", "zlib"},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing dependency",
|
||||||
|
pkg: &PackageInfo{
|
||||||
|
Name: "testpkg2",
|
||||||
|
Version: "1.0",
|
||||||
|
Dependencies: []string{"nonexistent"},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "version constraint not satisfied",
|
||||||
|
pkg: &PackageInfo{
|
||||||
|
Name: "testpkg3",
|
||||||
|
Version: "1.0",
|
||||||
|
Dependencies: []string{"glibc>=2.40"},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "alternative dependencies",
|
||||||
|
pkg: &PackageInfo{
|
||||||
|
Name: "testpkg4",
|
||||||
|
Version: "1.0",
|
||||||
|
Dependencies: []string{"lua5.1 | luajit"},
|
||||||
|
},
|
||||||
|
want: false, // neither alternative is installed
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := resolver.CheckDependencies(tc.pkg)
|
||||||
|
got := err == nil
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("CheckDependencies() = %v, want %v", got, tc.want)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDependencyResolver_ResolveOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
packages := []*PackageInfo{
|
||||||
|
{
|
||||||
|
Name: "a",
|
||||||
|
Version: "1.0",
|
||||||
|
Dependencies: []string{"b"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "b",
|
||||||
|
Version: "1.0",
|
||||||
|
Dependencies: []string{"c"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "c",
|
||||||
|
Version: "1.0",
|
||||||
|
Dependencies: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
installed := map[string]*PackageInfo{}
|
||||||
|
repo := NewMockPackageRepository(installed)
|
||||||
|
resolver := NewDependencyResolver(repo)
|
||||||
|
|
||||||
|
order, err := resolver.ResolveOrder(packages)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveOrder() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that dependencies come before dependents
|
||||||
|
pos := make(map[string]int)
|
||||||
|
for i, pkg := range order {
|
||||||
|
pos[pkg.Name] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
if pos["c"] > pos["b"] || pos["b"] > pos["a"] {
|
||||||
|
t.Errorf("Dependencies not in correct order: got %v", order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDependencyResolver_FindMissingDependencies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
installed := map[string]*PackageInfo{
|
||||||
|
"pkg1": {Name: "pkg1", Version: "1.0"},
|
||||||
|
"pkg2": {Name: "pkg2", Version: "2.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := NewMockPackageRepository(installed)
|
||||||
|
resolver := NewDependencyResolver(repo)
|
||||||
|
|
||||||
|
pkg := &PackageInfo{
|
||||||
|
Name: "testpkg",
|
||||||
|
Version: "1.0",
|
||||||
|
Dependencies: []string{"pkg1", "pkg3", "pkg4>=1.5"},
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := resolver.findMissingDependencies(pkg, installed)
|
||||||
|
|
||||||
|
expected := []string{"pkg3", "pkg4>=1.5"}
|
||||||
|
if len(missing) != len(expected) {
|
||||||
|
t.Fatalf("Expected %d missing dependencies, got %d", len(expected), len(missing))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, dep := range expected {
|
||||||
|
if i >= len(missing) || missing[i] != dep {
|
||||||
|
t.Errorf("Expected missing dependency %s, got %s", dep, missing[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
pkg/errors/errors.go
Normal file
122
pkg/errors/errors.go
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorCode represents different types of errors
|
||||||
|
type ErrorCode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrCodeUnknown ErrorCode = iota
|
||||||
|
ErrCodePackageNotFound
|
||||||
|
ErrCodeDependencyMissing
|
||||||
|
ErrCodeDependencyConflict
|
||||||
|
ErrCodeVersionMismatch
|
||||||
|
ErrCodeFileConflict
|
||||||
|
ErrCodeInvalidPath
|
||||||
|
ErrCodeTransactionFailed
|
||||||
|
ErrCodeBuildFailed
|
||||||
|
ErrCodeChecksumMismatch
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error represents a typed error with context
|
||||||
|
type Error struct {
|
||||||
|
Code ErrorCode
|
||||||
|
Message string
|
||||||
|
Context map[string]string
|
||||||
|
Cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
if e.Cause != nil {
|
||||||
|
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
|
||||||
|
}
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Unwrap() error {
|
||||||
|
return e.Cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewError creates a new typed error
|
||||||
|
func NewError(code ErrorCode, message string, context map[string]string) *Error {
|
||||||
|
return &Error{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Context: context,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapError wraps an existing error with context
|
||||||
|
func WrapError(code ErrorCode, message string, cause error, context map[string]string) *Error {
|
||||||
|
return &Error{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Context: context,
|
||||||
|
Cause: cause,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrorCode checks if error matches specific code
|
||||||
|
func IsErrorCode(err error, code ErrorCode) bool {
|
||||||
|
if typedErr, ok := err.(*Error); ok {
|
||||||
|
return typedErr.Code == code
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common error constructors
|
||||||
|
func NewPackageNotFoundError(pkg string) *Error {
|
||||||
|
return NewError(ErrCodePackageNotFound, "package not found", map[string]string{
|
||||||
|
"package": pkg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDependencyMissingError(pkg, dep string) *Error {
|
||||||
|
return NewError(ErrCodeDependencyMissing, "missing dependency", map[string]string{
|
||||||
|
"package": pkg,
|
||||||
|
"dependency": dep,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVersionMismatchError(pkg, required, installed string) *Error {
|
||||||
|
return NewError(ErrCodeVersionMismatch, "version mismatch", map[string]string{
|
||||||
|
"package": pkg,
|
||||||
|
"required": required,
|
||||||
|
"installed": installed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileConflictError(pkg, path string) *Error {
|
||||||
|
return NewError(ErrCodeFileConflict, "file conflict", map[string]string{
|
||||||
|
"package": pkg,
|
||||||
|
"path": path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInvalidPathError(path string) *Error {
|
||||||
|
return NewError(ErrCodeInvalidPath, "invalid path", map[string]string{
|
||||||
|
"path": path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTransactionFailedError(operation string, cause error) *Error {
|
||||||
|
return WrapError(ErrCodeTransactionFailed, "transaction failed: "+operation, cause, map[string]string{
|
||||||
|
"operation": operation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBuildFailedError(pkg string, cause error) *Error {
|
||||||
|
return WrapError(ErrCodeBuildFailed, "build failed", cause, map[string]string{
|
||||||
|
"package": pkg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChecksumMismatchError(file, expected, actual string) *Error {
|
||||||
|
return NewError(ErrCodeChecksumMismatch, "checksum mismatch", map[string]string{
|
||||||
|
"file": file,
|
||||||
|
"expected": expected,
|
||||||
|
"actual": actual,
|
||||||
|
})
|
||||||
|
}
|
||||||
150
pkg/errors/errors_test.go
Normal file
150
pkg/errors/errors_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestError_Error(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Test error without cause
|
||||||
|
err1 := NewPackageNotFoundError("testpkg")
|
||||||
|
if err1.Error() != "package not found" {
|
||||||
|
t.Errorf("Expected 'package not found', got %q", err1.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test error with cause
|
||||||
|
cause := errors.New("underlying error")
|
||||||
|
err2 := WrapError(ErrCodeBuildFailed, "build failed", cause, map[string]string{"package": "testpkg"})
|
||||||
|
expected := "build failed: underlying error"
|
||||||
|
if err2.Error() != expected {
|
||||||
|
t.Errorf("Expected %q, got %q", expected, err2.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestError_Unwrap(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cause := errors.New("cause")
|
||||||
|
err := WrapError(ErrCodeTransactionFailed, "transaction failed", cause, nil)
|
||||||
|
|
||||||
|
if err.Unwrap() != cause {
|
||||||
|
t.Errorf("Expected unwrapped error to be the cause")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsErrorCode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := NewPackageNotFoundError("testpkg")
|
||||||
|
|
||||||
|
if !IsErrorCode(err, ErrCodePackageNotFound) {
|
||||||
|
t.Errorf("Expected error to be ErrCodePackageNotFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
if IsErrorCode(err, ErrCodeDependencyMissing) {
|
||||||
|
t.Errorf("Expected error not to be ErrCodeDependencyMissing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with non-typed error
|
||||||
|
plainErr := errors.New("plain error")
|
||||||
|
if IsErrorCode(plainErr, ErrCodePackageNotFound) {
|
||||||
|
t.Errorf("Expected plain error not to match any error code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorConstructors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
constructor func() *Error
|
||||||
|
expectedCode ErrorCode
|
||||||
|
expectedMessage string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "PackageNotFound",
|
||||||
|
constructor: func() *Error { return NewPackageNotFoundError("testpkg") },
|
||||||
|
expectedCode: ErrCodePackageNotFound,
|
||||||
|
expectedMessage: "package not found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "DependencyMissing",
|
||||||
|
constructor: func() *Error { return NewDependencyMissingError("pkg", "dep") },
|
||||||
|
expectedCode: ErrCodeDependencyMissing,
|
||||||
|
expectedMessage: "missing dependency",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VersionMismatch",
|
||||||
|
constructor: func() *Error { return NewVersionMismatchError("pkg", "1.0", "0.9") },
|
||||||
|
expectedCode: ErrCodeVersionMismatch,
|
||||||
|
expectedMessage: "version mismatch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FileConflict",
|
||||||
|
constructor: func() *Error { return NewFileConflictError("pkg", "/path") },
|
||||||
|
expectedCode: ErrCodeFileConflict,
|
||||||
|
expectedMessage: "file conflict",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "InvalidPath",
|
||||||
|
constructor: func() *Error { return NewInvalidPathError("../../../etc/passwd") },
|
||||||
|
expectedCode: ErrCodeInvalidPath,
|
||||||
|
expectedMessage: "invalid path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ChecksumMismatch",
|
||||||
|
constructor: func() *Error { return NewChecksumMismatchError("file.tar.gz", "abc123", "def456") },
|
||||||
|
expectedCode: ErrCodeChecksumMismatch,
|
||||||
|
expectedMessage: "checksum mismatch",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := tc.constructor()
|
||||||
|
|
||||||
|
if err.Code != tc.expectedCode {
|
||||||
|
t.Errorf("Expected error code %v, got %v", tc.expectedCode, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err.Message != tc.expectedMessage {
|
||||||
|
t.Errorf("Expected message %q, got %q", tc.expectedMessage, err.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err.Context == nil {
|
||||||
|
t.Errorf("Expected context to be set")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := NewPackageNotFoundError("testpkg")
|
||||||
|
|
||||||
|
if err.Context["package"] != "testpkg" {
|
||||||
|
t.Errorf("Expected package context to be 'testpkg', got %q", err.Context["package"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErrorWrapping(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cause := errors.New("underlying")
|
||||||
|
err := NewTransactionFailedError("install", cause)
|
||||||
|
|
||||||
|
if err.Code != ErrCodeTransactionFailed {
|
||||||
|
t.Errorf("Expected ErrCodeTransactionFailed, got %v", err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err.Cause != cause {
|
||||||
|
t.Errorf("Expected cause to be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err.Context["operation"] != "install" {
|
||||||
|
t.Errorf("Expected operation context to be 'install'")
|
||||||
|
}
|
||||||
|
}
|
||||||
189
pkg/installer/auto_resolve_test.go
Normal file
189
pkg/installer/auto_resolve_test.go
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"zsvo/pkg/deps"
|
||||||
|
"zsvo/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstaller_BuildPackageIndex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create temporary directory structure
|
||||||
|
tempDir, err := os.MkdirTemp("", "zsvo-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create package directory
|
||||||
|
pkgDir := filepath.Join(tempDir, "packages")
|
||||||
|
if err := os.MkdirAll(pkgDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create package dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create root directory
|
||||||
|
rootDir := filepath.Join(tempDir, "root")
|
||||||
|
if err := os.MkdirAll(rootDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create root dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installer := NewInstaller(rootDir)
|
||||||
|
|
||||||
|
// Test with empty directory
|
||||||
|
index, err := installer.buildPackageIndex([]string{pkgDir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to build package index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(index) != 0 {
|
||||||
|
t.Errorf("Expected 0 packages in index, got %d", len(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with non-existent directory
|
||||||
|
index, err = installer.buildPackageIndex([]string{"/non/existent/path"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to build package index with non-existent path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(index) != 0 {
|
||||||
|
t.Errorf("Expected 0 packages in index for non-existent path, got %d", len(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstaller_FindMissingDependencies(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create temporary directory structure
|
||||||
|
tempDir, err := os.MkdirTemp("", "zsvo-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create root directory
|
||||||
|
rootDir := filepath.Join(tempDir, "root")
|
||||||
|
if err := os.MkdirAll(rootDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create root dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installer := NewInstaller(rootDir)
|
||||||
|
|
||||||
|
// Test with package that has no dependencies
|
||||||
|
pkgInfo := &types.PkgInfo{
|
||||||
|
Name: "testpkg",
|
||||||
|
Version: "1.0",
|
||||||
|
Dependencies: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
installed := make(map[string]*deps.PackageInfo)
|
||||||
|
candidates := make(map[string]installCandidate)
|
||||||
|
|
||||||
|
missing, err := installer.findMissingDependencies(pkgInfo, installed, candidates)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to find missing dependencies: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missing) != 0 {
|
||||||
|
t.Errorf("Expected 0 missing dependencies, got %d", len(missing))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with package that has missing dependency
|
||||||
|
pkgInfoWithDeps := &types.PkgInfo{
|
||||||
|
Name: "testpkg",
|
||||||
|
Version: "1.0",
|
||||||
|
Dependencies: []string{"missingdep"},
|
||||||
|
}
|
||||||
|
|
||||||
|
missing, err = installer.findMissingDependencies(pkgInfoWithDeps, installed, candidates)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to find missing dependencies: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missing) != 1 {
|
||||||
|
t.Errorf("Expected 1 missing dependency, got %d", len(missing))
|
||||||
|
}
|
||||||
|
|
||||||
|
if missing[0] != "missingdep" {
|
||||||
|
t.Errorf("Expected missing dependency 'missingdep', got %s", missing[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstaller_ResolveDependenciesFromSearch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create temporary directory structure
|
||||||
|
tempDir, err := os.MkdirTemp("", "zsvo-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create root directory
|
||||||
|
rootDir := filepath.Join(tempDir, "root")
|
||||||
|
if err := os.MkdirAll(rootDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create root dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installer := NewInstaller(rootDir)
|
||||||
|
|
||||||
|
// Test with no available packages
|
||||||
|
pkgInfo := &types.PkgInfo{
|
||||||
|
Name: "testpkg",
|
||||||
|
Version: "1.0",
|
||||||
|
Dependencies: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate := installCandidate{
|
||||||
|
path: "/fake/path/testpkg.pkg.tar.zst",
|
||||||
|
info: pkgInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := []installCandidate{candidate}
|
||||||
|
searchPaths := []string{"/non/existent/path"}
|
||||||
|
|
||||||
|
resolved, err := installer.resolveDependenciesFromSearch(candidates, searchPaths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to resolve dependencies: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should resolve just the single package since it has no deps
|
||||||
|
if len(resolved) != 1 {
|
||||||
|
t.Errorf("Expected 1 package resolved, got %d", len(resolved))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved[0].info.Name != "testpkg" {
|
||||||
|
t.Errorf("Expected resolved package 'testpkg', got %s", resolved[0].info.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstaller_InstallWithAutoResolve_MissingDeps(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create temporary directory structure
|
||||||
|
tempDir, err := os.MkdirTemp("", "zsvo-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create root directory
|
||||||
|
rootDir := filepath.Join(tempDir, "root")
|
||||||
|
if err := os.MkdirAll(rootDir, 0755); err != nil {
|
||||||
|
t.Fatalf("Failed to create root dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
installer := NewInstaller(rootDir)
|
||||||
|
|
||||||
|
// Test with non-existent package file
|
||||||
|
nonExistentFile := filepath.Join(tempDir, "nonexistent.pkg.tar.zst")
|
||||||
|
|
||||||
|
err = installer.InstallWithAutoResolve([]string{nonExistentFile}, []string{})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expected error for non-existent package file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"zsvo/pkg/deps"
|
"zsvo/pkg/deps"
|
||||||
|
"zsvo/pkg/errors"
|
||||||
"zsvo/pkg/packager"
|
"zsvo/pkg/packager"
|
||||||
"zsvo/pkg/types"
|
"zsvo/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
@ -19,6 +20,8 @@ import (
|
||||||
type Installer struct {
|
type Installer struct {
|
||||||
rootDir string
|
rootDir string
|
||||||
pkgDB string
|
pkgDB string
|
||||||
|
resolver *deps.DependencyResolver
|
||||||
|
repo *InstallerPackageRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveOptions controls package removal behavior.
|
// RemoveOptions controls package removal behavior.
|
||||||
|
|
@ -27,10 +30,16 @@ type RemoveOptions struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInstaller(rootDir string) *Installer {
|
func NewInstaller(rootDir string) *Installer {
|
||||||
return &Installer{
|
inst := &Installer{
|
||||||
rootDir: rootDir,
|
rootDir: rootDir,
|
||||||
pkgDB: filepath.Join(rootDir, "var", "lib", "pkgdb"),
|
pkgDB: filepath.Join(rootDir, "var", "lib", "pkgdb"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize repository and resolver
|
||||||
|
inst.repo = NewInstallerPackageRepository(inst)
|
||||||
|
inst.resolver = deps.NewDependencyResolver(inst.repo)
|
||||||
|
|
||||||
|
return inst
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install installs a single package archive produced by zsvo build.
|
// Install installs a single package archive produced by zsvo build.
|
||||||
|
|
@ -49,6 +58,36 @@ func (i *Installer) InstallMany(packagePaths []string) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InstallWithAutoDeps installs packages with automatic dependency resolution
|
||||||
|
func (i *Installer) InstallWithAutoDeps(packagePaths []string) error {
|
||||||
|
if len(packagePaths) == 0 {
|
||||||
|
return fmt.Errorf("no packages provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
return i.withDBLock(true, func() error {
|
||||||
|
// Read all package candidates
|
||||||
|
candidates, err := i.readInstallCandidatesNoLock(packagePaths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve all dependencies recursively
|
||||||
|
allPackages, err := i.resolveAllDependencies(candidates)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to package paths
|
||||||
|
finalPaths := make([]string, 0, len(allPackages))
|
||||||
|
for _, pkg := range allPackages {
|
||||||
|
finalPaths = append(finalPaths, pkg.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install all packages in correct order
|
||||||
|
return i.installManyNoLock(finalPaths)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Upgrade upgrades packages from local package files.
|
// Upgrade upgrades packages from local package files.
|
||||||
func (i *Installer) Upgrade(packagePaths []string) error {
|
func (i *Installer) Upgrade(packagePaths []string) error {
|
||||||
return i.InstallMany(packagePaths)
|
return i.InstallMany(packagePaths)
|
||||||
|
|
@ -206,6 +245,16 @@ func (i *Installer) readInstallCandidatesNoLock(packagePaths []string) ([]instal
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Installer) installPackageTxNoLock(packagePath, txDir string, installedInfos map[string]*types.PkgInfo) (*installTransactionState, error) {
|
func (i *Installer) installPackageTxNoLock(packagePath, txDir string, installedInfos map[string]*types.PkgInfo) (*installTransactionState, error) {
|
||||||
|
if packagePath == "" {
|
||||||
|
return nil, fmt.Errorf("package path cannot be empty")
|
||||||
|
}
|
||||||
|
if txDir == "" {
|
||||||
|
return nil, fmt.Errorf("transaction directory cannot be empty")
|
||||||
|
}
|
||||||
|
if installedInfos == nil {
|
||||||
|
return nil, fmt.Errorf("installed infos map cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
pkgTxDir, err := os.MkdirTemp(txDir, "pkg-")
|
pkgTxDir, err := os.MkdirTemp(txDir, "pkg-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create package transaction directory: %w", err)
|
return nil, fmt.Errorf("failed to create package transaction directory: %w", err)
|
||||||
|
|
@ -221,6 +270,9 @@ func (i *Installer) installPackageTxNoLock(packagePath, txDir string, installedI
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read package metadata: %w", err)
|
return nil, fmt.Errorf("failed to read package metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
if pkgInfo == nil {
|
||||||
|
return nil, fmt.Errorf("package metadata is nil")
|
||||||
|
}
|
||||||
if err := validateSimpleDependencies(pkgInfo.Dependencies); err != nil {
|
if err := validateSimpleDependencies(pkgInfo.Dependencies); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -317,6 +369,13 @@ func (i *Installer) rollbackInstallTransactionNoLock(applied []installTransactio
|
||||||
errs = append(errs, fmt.Sprintf("failed to re-register %s: %v", state.replaced.pkgName, err))
|
errs = append(errs, fmt.Sprintf("failed to re-register %s: %v", state.replaced.pkgName, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up transaction directory
|
||||||
|
if state.txDir != "" {
|
||||||
|
if err := os.RemoveAll(state.txDir); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("failed to cleanup transaction directory for %s: %v", state.pkgInfo.Name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
|
|
@ -821,6 +880,9 @@ func removePathIfExists(path string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSimpleDependencies(rawDeps []string) error {
|
func validateSimpleDependencies(rawDeps []string) error {
|
||||||
|
if rawDeps == nil {
|
||||||
|
return nil // Empty dependencies are valid
|
||||||
|
}
|
||||||
if _, err := deps.ParseRequirements(rawDeps); err != nil {
|
if _, err := deps.ParseRequirements(rawDeps); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -1309,6 +1371,347 @@ func (i *Installer) GetPackageSize(packageName string) (int64, error) {
|
||||||
return totalSize, nil
|
return totalSize, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveAllDependencies recursively resolves all dependencies for packages
|
||||||
|
func (i *Installer) resolveAllDependencies(initialCandidates []installCandidate) ([]installCandidate, error) {
|
||||||
|
installed, err := i.installedPkgInfosNoLock()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert installed to deps.PackageInfo format
|
||||||
|
installedMap := make(map[string]*deps.PackageInfo, len(installed))
|
||||||
|
for name, info := range installed {
|
||||||
|
installedMap[name] = &deps.PackageInfo{
|
||||||
|
Name: info.Name,
|
||||||
|
Version: info.Version,
|
||||||
|
Dependencies: info.Dependencies,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track all packages to install
|
||||||
|
allPackages := make(map[string]installCandidate)
|
||||||
|
|
||||||
|
// Add initial packages
|
||||||
|
for _, candidate := range initialCandidates {
|
||||||
|
allPackages[candidate.info.Name] = candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process dependencies recursively
|
||||||
|
toProcess := make([]installCandidate, len(initialCandidates))
|
||||||
|
copy(toProcess, initialCandidates)
|
||||||
|
|
||||||
|
for len(toProcess) > 0 {
|
||||||
|
current := toProcess[0]
|
||||||
|
toProcess = toProcess[1:]
|
||||||
|
|
||||||
|
// Check dependencies
|
||||||
|
missing, err := i.findMissingDependencies(current.info, installedMap, allPackages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find missing dependencies in available packages
|
||||||
|
for _, depName := range missing {
|
||||||
|
if _, exists := allPackages[depName]; exists {
|
||||||
|
continue // Already in our list
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find the dependency package
|
||||||
|
depCandidate, err := i.findDependencyPackage(depName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.NewDependencyMissingError(current.info.Name, depName)
|
||||||
|
}
|
||||||
|
|
||||||
|
allPackages[depName] = depCandidate
|
||||||
|
toProcess = append(toProcess, depCandidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert map to slice and resolve installation order
|
||||||
|
packageList := make([]*deps.PackageInfo, 0, len(allPackages))
|
||||||
|
for _, candidate := range allPackages {
|
||||||
|
packageList = append(packageList, &deps.PackageInfo{
|
||||||
|
Name: candidate.info.Name,
|
||||||
|
Version: candidate.info.Version,
|
||||||
|
Dependencies: candidate.info.Dependencies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered, err := i.resolver.ResolveOrder(packageList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to installCandidate slice
|
||||||
|
result := make([]installCandidate, 0, len(ordered))
|
||||||
|
for _, pkg := range ordered {
|
||||||
|
result = append(result, allPackages[pkg.Name])
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMissingDependencies finds dependencies that are not satisfied
|
||||||
|
func (i *Installer) findMissingDependencies(pkgInfo *types.PkgInfo, installed map[string]*deps.PackageInfo, candidates map[string]installCandidate) ([]string, error) {
|
||||||
|
var missing []string
|
||||||
|
|
||||||
|
reqs, err := deps.ParseRequirements(pkgInfo.Dependencies)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, req := range reqs {
|
||||||
|
satisfied := false
|
||||||
|
|
||||||
|
// Check installed packages
|
||||||
|
for _, alt := range req.Alternatives {
|
||||||
|
if installedPkg, exists := installed[alt.Name]; exists {
|
||||||
|
if alt.MatchesVersion(installedPkg.Version) {
|
||||||
|
satisfied = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if satisfied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check candidates (packages we're about to install)
|
||||||
|
for _, alt := range req.Alternatives {
|
||||||
|
if candidate, exists := candidates[alt.Name]; exists {
|
||||||
|
if alt.MatchesVersion(candidate.info.Version) {
|
||||||
|
satisfied = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if satisfied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not satisfied by either, it's missing
|
||||||
|
missing = append(missing, req.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findDependencyPackage tries to find a dependency package in the system
|
||||||
|
func (i *Installer) findDependencyPackage(depName string) (installCandidate, error) {
|
||||||
|
// This is where you would implement package discovery
|
||||||
|
// For now, we'll return an error to indicate the dependency wasn't found
|
||||||
|
// In a real implementation, you might:
|
||||||
|
// 1. Search package repositories
|
||||||
|
// 2. Query package indexes
|
||||||
|
// 3. Use auto-build from source like in cmd/install.go
|
||||||
|
|
||||||
|
return installCandidate{}, errors.NewPackageNotFoundError(depName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallWithAutoResolve installs packages with automatic dependency resolution from available packages
|
||||||
|
func (i *Installer) InstallWithAutoResolve(packagePaths []string, searchPaths []string) error {
|
||||||
|
if len(packagePaths) == 0 {
|
||||||
|
return fmt.Errorf("no packages provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
return i.withDBLock(true, func() error {
|
||||||
|
// Read all package candidates
|
||||||
|
candidates, err := i.readInstallCandidatesNoLock(packagePaths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve dependencies using available packages
|
||||||
|
allPackages, err := i.resolveDependenciesFromSearch(candidates, searchPaths)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to package paths
|
||||||
|
finalPaths := make([]string, 0, len(allPackages))
|
||||||
|
for _, pkg := range allPackages {
|
||||||
|
finalPaths = append(finalPaths, pkg.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install all packages in correct order
|
||||||
|
return i.installManyNoLock(finalPaths)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDependenciesFromSearch resolves dependencies by searching in specified paths
|
||||||
|
func (i *Installer) resolveDependenciesFromSearch(initialCandidates []installCandidate, searchPaths []string) ([]installCandidate, error) {
|
||||||
|
// Build index of available packages from search paths
|
||||||
|
availablePackages, err := i.buildPackageIndex(searchPaths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
installed, err := i.installedPkgInfosNoLock()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track all packages to install
|
||||||
|
allPackages := make(map[string]installCandidate)
|
||||||
|
|
||||||
|
// Add initial packages
|
||||||
|
for _, candidate := range initialCandidates {
|
||||||
|
allPackages[candidate.info.Name] = candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process dependencies recursively
|
||||||
|
toProcess := make([]installCandidate, len(initialCandidates))
|
||||||
|
copy(toProcess, initialCandidates)
|
||||||
|
|
||||||
|
for len(toProcess) > 0 {
|
||||||
|
current := toProcess[0]
|
||||||
|
toProcess = toProcess[1:]
|
||||||
|
|
||||||
|
// Check dependencies
|
||||||
|
missing, err := i.findMissingDependenciesFromSearch(current.info, installed, allPackages, availablePackages)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add missing dependencies
|
||||||
|
for _, depName := range missing {
|
||||||
|
if _, exists := allPackages[depName]; exists {
|
||||||
|
continue // Already in our list
|
||||||
|
}
|
||||||
|
|
||||||
|
if depPkg, exists := availablePackages[depName]; exists {
|
||||||
|
allPackages[depName] = depPkg
|
||||||
|
toProcess = append(toProcess, depPkg)
|
||||||
|
} else {
|
||||||
|
return nil, errors.NewDependencyMissingError(current.info.Name, depName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve installation order
|
||||||
|
packageList := make([]*deps.PackageInfo, 0, len(allPackages))
|
||||||
|
for _, candidate := range allPackages {
|
||||||
|
packageList = append(packageList, &deps.PackageInfo{
|
||||||
|
Name: candidate.info.Name,
|
||||||
|
Version: candidate.info.Version,
|
||||||
|
Dependencies: candidate.info.Dependencies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered, err := i.resolver.ResolveOrder(packageList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert back to installCandidate slice
|
||||||
|
result := make([]installCandidate, 0, len(ordered))
|
||||||
|
for _, pkg := range ordered {
|
||||||
|
result = append(result, allPackages[pkg.Name])
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPackageIndex builds an index of available packages from search paths
|
||||||
|
func (i *Installer) buildPackageIndex(searchPaths []string) (map[string]installCandidate, error) {
|
||||||
|
index := make(map[string]installCandidate)
|
||||||
|
|
||||||
|
for _, searchPath := range searchPaths {
|
||||||
|
entries, err := os.ReadDir(searchPath)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip paths that don't exist or aren't readable
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !strings.HasSuffix(entry.Name(), ".pkg.tar.zst") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
packagePath := filepath.Join(searchPath, entry.Name())
|
||||||
|
pkgInfo, err := i.readPackageInfo(packagePath)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip invalid packages
|
||||||
|
}
|
||||||
|
|
||||||
|
index[pkgInfo.Name] = installCandidate{
|
||||||
|
path: packagePath,
|
||||||
|
info: pkgInfo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return index, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMissingDependenciesFromSearch finds missing dependencies by searching available packages
|
||||||
|
func (i *Installer) findMissingDependenciesFromSearch(pkgInfo *types.PkgInfo, installed map[string]*types.PkgInfo, candidates map[string]installCandidate, available map[string]installCandidate) ([]string, error) {
|
||||||
|
var missing []string
|
||||||
|
|
||||||
|
reqs, err := deps.ParseRequirements(pkgInfo.Dependencies)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, req := range reqs {
|
||||||
|
satisfied := false
|
||||||
|
|
||||||
|
// Check installed packages
|
||||||
|
for _, alt := range req.Alternatives {
|
||||||
|
if installedPkg, exists := installed[alt.Name]; exists {
|
||||||
|
if alt.MatchesVersion(installedPkg.Version) {
|
||||||
|
satisfied = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if satisfied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check candidates
|
||||||
|
for _, alt := range req.Alternatives {
|
||||||
|
if candidate, exists := candidates[alt.Name]; exists {
|
||||||
|
if alt.MatchesVersion(candidate.info.Version) {
|
||||||
|
satisfied = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if satisfied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check available packages
|
||||||
|
for _, alt := range req.Alternatives {
|
||||||
|
if availablePkg, exists := available[alt.Name]; exists {
|
||||||
|
if alt.MatchesVersion(availablePkg.info.Version) {
|
||||||
|
satisfied = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if satisfied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not satisfied by any, it's missing
|
||||||
|
missing = append(missing, req.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return missing, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPackageInfo reads package info from a package file
|
||||||
|
func (i *Installer) readPackageInfo(packagePath string) (*types.PkgInfo, error) {
|
||||||
|
p := packager.NewPackager(i.rootDir)
|
||||||
|
return p.ReadPkgInfo(packagePath)
|
||||||
|
}
|
||||||
|
|
||||||
type installCandidate struct {
|
type installCandidate struct {
|
||||||
path string
|
path string
|
||||||
info *types.PkgInfo
|
info *types.PkgInfo
|
||||||
|
|
|
||||||
47
pkg/installer/repository_adapter.go
Normal file
47
pkg/installer/repository_adapter.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package installer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"zsvo/pkg/deps"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstallerPackageRepository implements deps.PackageRepository for Installer
|
||||||
|
type InstallerPackageRepository struct {
|
||||||
|
installer *Installer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInstallerPackageRepository(installer *Installer) *InstallerPackageRepository {
|
||||||
|
return &InstallerPackageRepository{installer: installer}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InstallerPackageRepository) GetInstalled() (map[string]*deps.PackageInfo, error) {
|
||||||
|
installed, err := r.installer.installedPkgInfosNoLock()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get installed packages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]*deps.PackageInfo, len(installed))
|
||||||
|
for name, info := range installed {
|
||||||
|
result[name] = &deps.PackageInfo{
|
||||||
|
Name: info.Name,
|
||||||
|
Version: info.Version,
|
||||||
|
Dependencies: info.Dependencies,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InstallerPackageRepository) GetPackage(name string) (*deps.PackageInfo, error) {
|
||||||
|
info, err := r.installer.getPackageInfoNoLock(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &deps.PackageInfo{
|
||||||
|
Name: info.Name,
|
||||||
|
Version: info.Version,
|
||||||
|
Dependencies: info.Dependencies,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
@ -132,20 +132,39 @@ func (p *Packager) writePkgInfo(path string, pkgInfo *types.PkgInfo) error {
|
||||||
func (p *Packager) createArchive(sourceDir, archivePath string) error {
|
func (p *Packager) createArchive(sourceDir, archivePath string) error {
|
||||||
outFile, err := os.Create(archivePath)
|
outFile, err := os.Create(archivePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to create archive file: %w", err)
|
||||||
}
|
}
|
||||||
defer outFile.Close()
|
|
||||||
|
// Ensure file is closed on error
|
||||||
|
var closeErr error
|
||||||
|
defer func() {
|
||||||
|
if cerr := outFile.Close(); cerr != nil {
|
||||||
|
closeErr = cerr
|
||||||
|
}
|
||||||
|
// Remove partial file on error
|
||||||
|
if err != nil || closeErr != nil {
|
||||||
|
os.Remove(archivePath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
zstdWriter, err := zstd.NewWriter(outFile)
|
zstdWriter, err := zstd.NewWriter(outFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to create zstd writer: %w", err)
|
||||||
}
|
}
|
||||||
defer zstdWriter.Close()
|
defer func() {
|
||||||
|
if cerr := zstdWriter.Close(); cerr != nil {
|
||||||
|
closeErr = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
tarWriter := tar.NewWriter(zstdWriter)
|
tarWriter := tar.NewWriter(zstdWriter)
|
||||||
defer tarWriter.Close()
|
defer func() {
|
||||||
|
if cerr := tarWriter.Close(); cerr != nil {
|
||||||
|
closeErr = cerr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, walkErr error) error {
|
walkErr := filepath.Walk(sourceDir, func(path string, info os.FileInfo, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
return walkErr
|
return walkErr
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +174,7 @@ func (p *Packager) createArchive(sourceDir, archivePath string) error {
|
||||||
|
|
||||||
relPath, err := filepath.Rel(sourceDir, path)
|
relPath, err := filepath.Rel(sourceDir, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to get relative path for %s: %w", path, err)
|
||||||
}
|
}
|
||||||
relPath = filepath.ToSlash(relPath)
|
relPath = filepath.ToSlash(relPath)
|
||||||
|
|
||||||
|
|
@ -163,18 +182,18 @@ func (p *Packager) createArchive(sourceDir, archivePath string) error {
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
linkTarget, err = os.Readlink(path)
|
linkTarget, err = os.Readlink(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to read symlink %s: %w", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header, err := tar.FileInfoHeader(info, linkTarget)
|
header, err := tar.FileInfoHeader(info, linkTarget)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to create tar header for %s: %w", path, err)
|
||||||
}
|
}
|
||||||
header.Name = relPath
|
header.Name = relPath
|
||||||
|
|
||||||
if err := tarWriter.WriteHeader(header); err != nil {
|
if err := tarWriter.WriteHeader(header); err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to write tar header for %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !info.Mode().IsRegular() {
|
if !info.Mode().IsRegular() {
|
||||||
|
|
@ -183,18 +202,24 @@ func (p *Packager) createArchive(sourceDir, archivePath string) error {
|
||||||
|
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to open file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
if _, err := io.Copy(tarWriter, file); err != nil {
|
if _, err := io.Copy(tarWriter, file); err != nil {
|
||||||
file.Close()
|
return fmt.Errorf("failed to copy file %s to archive: %w", path, err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if err := file.Close(); err != nil {
|
return nil
|
||||||
return err
|
})
|
||||||
|
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return closeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract extracts a package archive
|
// Extract extracts a package archive
|
||||||
|
|
|
||||||
|
|
@ -79,15 +79,27 @@ func ParseRecipeFromReader(r io.Reader) (*Recipe, error) {
|
||||||
|
|
||||||
switch section {
|
switch section {
|
||||||
case "build":
|
case "build":
|
||||||
|
if item == "" {
|
||||||
|
return nil, fmt.Errorf("build command cannot be empty at line %d", lineNo)
|
||||||
|
}
|
||||||
rcp.Build = append(rcp.Build, item)
|
rcp.Build = append(rcp.Build, item)
|
||||||
case "install":
|
case "install":
|
||||||
|
if item == "" {
|
||||||
|
return nil, fmt.Errorf("install command cannot be empty at line %d", lineNo)
|
||||||
|
}
|
||||||
rcp.Install = append(rcp.Install, item)
|
rcp.Install = append(rcp.Install, item)
|
||||||
case "deps":
|
case "deps":
|
||||||
|
if item == "" {
|
||||||
|
return nil, fmt.Errorf("dependency cannot be empty at line %d", lineNo)
|
||||||
|
}
|
||||||
rcp.Deps = append(rcp.Deps, item)
|
rcp.Deps = append(rcp.Deps, item)
|
||||||
case "source":
|
case "source":
|
||||||
if sourceSubsection != "patches" {
|
if sourceSubsection != "patches" {
|
||||||
return nil, fmt.Errorf("unexpected source list item at line %d", lineNo)
|
return nil, fmt.Errorf("unexpected source list item at line %d", lineNo)
|
||||||
}
|
}
|
||||||
|
if item == "" {
|
||||||
|
return nil, fmt.Errorf("patch path cannot be empty at line %d", lineNo)
|
||||||
|
}
|
||||||
rcp.Source.Patches = append(rcp.Source.Patches, item)
|
rcp.Source.Patches = append(rcp.Source.Patches, item)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("list item outside section at line %d", lineNo)
|
return nil, fmt.Errorf("list item outside section at line %d", lineNo)
|
||||||
|
|
|
||||||
286
pkg/security/path_validator.go
Normal file
286
pkg/security/path_validator.go
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"zsvo/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PathValidator handles path validation and security
|
||||||
|
type PathValidator struct {
|
||||||
|
allowedPaths []string
|
||||||
|
strictMode bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPathValidator creates a new path validator
|
||||||
|
func NewPathValidator(allowedPaths []string, strictMode bool) *PathValidator {
|
||||||
|
return &PathValidator{
|
||||||
|
allowedPaths: allowedPaths,
|
||||||
|
strictMode: strictMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePath validates a path for security issues
|
||||||
|
func (v *PathValidator) ValidatePath(path string) error {
|
||||||
|
// Clean the path
|
||||||
|
cleanPath := filepath.Clean(path)
|
||||||
|
|
||||||
|
// Check for path traversal attempts
|
||||||
|
if strings.Contains(cleanPath, "..") {
|
||||||
|
return errors.NewInvalidPathError(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for absolute paths (should be relative to package root)
|
||||||
|
if filepath.IsAbs(cleanPath) && v.strictMode {
|
||||||
|
return errors.NewInvalidPathError(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for suspicious patterns
|
||||||
|
if v.hasSuspiciousPatterns(cleanPath) {
|
||||||
|
return errors.NewInvalidPathError(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path is within allowed directories
|
||||||
|
if len(v.allowedPaths) > 0 {
|
||||||
|
if !v.isPathAllowed(cleanPath) {
|
||||||
|
return errors.NewInvalidPathError(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-platform path validation
|
||||||
|
if err := v.validateCrossPlatform(cleanPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizePath sanitizes a path for safe use
|
||||||
|
func (v *PathValidator) SanitizePath(path string) string {
|
||||||
|
// Remove any null bytes
|
||||||
|
path = strings.ReplaceAll(path, "\x00", "")
|
||||||
|
|
||||||
|
// Clean the path
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
|
||||||
|
// Convert forward slashes to OS-specific separators
|
||||||
|
path = filepath.FromSlash(path)
|
||||||
|
|
||||||
|
// Remove consecutive separators
|
||||||
|
for strings.Contains(path, string(filepath.Separator)+string(filepath.Separator)) {
|
||||||
|
path = strings.ReplaceAll(path, string(filepath.Separator)+string(filepath.Separator), string(filepath.Separator))
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFileName validates a filename for security
|
||||||
|
func (v *PathValidator) ValidateFileName(filename string) error {
|
||||||
|
if filename == "" {
|
||||||
|
return errors.NewInvalidPathError("empty filename")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reserved names
|
||||||
|
if v.isReservedName(filename) {
|
||||||
|
return errors.NewInvalidPathError(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for invalid characters
|
||||||
|
if v.hasInvalidChars(filename) {
|
||||||
|
return errors.NewInvalidPathError(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check length limits
|
||||||
|
if len(filename) > 255 {
|
||||||
|
return errors.NewInvalidPathError(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for trailing whitespace
|
||||||
|
if strings.HasSuffix(filename, " ") || strings.HasSuffix(filename, "\t") {
|
||||||
|
return errors.NewInvalidPathError(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasSuspiciousPatterns checks for suspicious path patterns
|
||||||
|
func (v *PathValidator) hasSuspiciousPatterns(path string) bool {
|
||||||
|
suspicious := []string{
|
||||||
|
"../",
|
||||||
|
"..\\",
|
||||||
|
"$",
|
||||||
|
"<",
|
||||||
|
">",
|
||||||
|
"|",
|
||||||
|
"\"",
|
||||||
|
}
|
||||||
|
|
||||||
|
pathLower := strings.ToLower(path)
|
||||||
|
for _, pattern := range suspicious {
|
||||||
|
if strings.Contains(pathLower, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for regex patterns (but allow ~ for home directories)
|
||||||
|
patterns := []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`^\.+$`), // Hidden files with only dots
|
||||||
|
regexp.MustCompile(`[^\x20-\x7E]`), // Non-ASCII characters
|
||||||
|
regexp.MustCompile(`\s+$`), // Trailing whitespace
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if pattern.MatchString(path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPathAllowed checks if path is within allowed directories
|
||||||
|
func (v *PathValidator) isPathAllowed(path string) bool {
|
||||||
|
// If no allowed paths specified, allow all
|
||||||
|
if len(v.allowedPaths) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, allowed := range v.allowedPaths {
|
||||||
|
allowedAbs, err := filepath.Abs(allowed)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(absPath+string(filepath.Separator), allowedAbs+string(filepath.Separator)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Also check exact match
|
||||||
|
if absPath == allowedAbs {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCrossPlatform performs cross-platform validation
|
||||||
|
func (v *PathValidator) validateCrossPlatform(path string) error {
|
||||||
|
// Windows-specific validations
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return v.validateWindowsPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unix-specific validations
|
||||||
|
return v.validateUnixPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateWindowsPath validates Windows-specific path issues
|
||||||
|
func (v *PathValidator) validateWindowsPath(path string) error {
|
||||||
|
// Check for invalid Windows characters
|
||||||
|
invalidChars := []string{"<", ">", ":", "\"", "|", "?", "*"}
|
||||||
|
for _, char := range invalidChars {
|
||||||
|
if strings.Contains(path, char) {
|
||||||
|
return errors.NewInvalidPathError(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reserved device names
|
||||||
|
reserved := []string{
|
||||||
|
"CON", "PRN", "AUX", "NUL",
|
||||||
|
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||||
|
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||||
|
}
|
||||||
|
|
||||||
|
pathUpper := strings.ToUpper(path)
|
||||||
|
for _, name := range reserved {
|
||||||
|
if strings.HasPrefix(pathUpper, name) {
|
||||||
|
return errors.NewInvalidPathError(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateUnixPath validates Unix-specific path issues
|
||||||
|
func (v *PathValidator) validateUnixPath(path string) error {
|
||||||
|
// Unix paths shouldn't contain backslashes
|
||||||
|
if strings.Contains(path, "\\") {
|
||||||
|
return errors.NewInvalidPathError(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isReservedName checks if filename is reserved
|
||||||
|
func (v *PathValidator) isReservedName(name string) bool {
|
||||||
|
reserved := []string{
|
||||||
|
"CON", "PRN", "AUX", "NUL",
|
||||||
|
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||||
|
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||||
|
}
|
||||||
|
|
||||||
|
nameUpper := strings.ToUpper(strings.TrimSuffix(name, filepath.Ext(name)))
|
||||||
|
for _, reserved := range reserved {
|
||||||
|
if nameUpper == reserved {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasInvalidChars checks for invalid characters in filename
|
||||||
|
func (v *PathValidator) hasInvalidChars(filename string) bool {
|
||||||
|
// Control characters
|
||||||
|
for _, r := range filename {
|
||||||
|
if unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform-specific invalid characters
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
invalid := "<>:\"|?*"
|
||||||
|
for _, char := range invalid {
|
||||||
|
if strings.ContainsRune(filename, char) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SafeJoin safely joins path components
|
||||||
|
func (v *PathValidator) SafeJoin(base, path string) (string, error) {
|
||||||
|
// If path is empty, just return cleaned base
|
||||||
|
if path == "" {
|
||||||
|
cleaned := filepath.Clean(base)
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the path component
|
||||||
|
if err := v.ValidatePath(path); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join paths
|
||||||
|
joined := filepath.Join(base, path)
|
||||||
|
|
||||||
|
// Clean and validate the result
|
||||||
|
cleaned := filepath.Clean(joined)
|
||||||
|
if err := v.ValidatePath(cleaned); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
289
pkg/security/path_validator_test.go
Normal file
289
pkg/security/path_validator_test.go
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPathValidator_ValidatePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
strict bool
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid relative path",
|
||||||
|
path: "usr/bin/test",
|
||||||
|
strict: true,
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path traversal attempt",
|
||||||
|
path: "../../../etc/passwd",
|
||||||
|
strict: true,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute path in strict mode",
|
||||||
|
path: "/usr/bin/test",
|
||||||
|
strict: true,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "absolute path in non-strict mode",
|
||||||
|
path: "/usr/bin/test",
|
||||||
|
strict: false,
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "suspicious characters",
|
||||||
|
path: "test$HOME",
|
||||||
|
strict: true,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hidden file with dots only",
|
||||||
|
path: "...",
|
||||||
|
strict: true,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid hidden file",
|
||||||
|
path: ".config",
|
||||||
|
strict: true,
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
validator := NewPathValidator(nil, tc.strict)
|
||||||
|
err := validator.ValidatePath(tc.path)
|
||||||
|
gotError := err != nil
|
||||||
|
|
||||||
|
if gotError != tc.wantError {
|
||||||
|
t.Errorf("ValidatePath(%q) error = %v, wantError %v", tc.path, err, tc.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathValidator_SanitizePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal path",
|
||||||
|
input: "usr/bin/test",
|
||||||
|
expected: "usr/bin/test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with null bytes",
|
||||||
|
input: "test\x00file",
|
||||||
|
expected: "testfile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with forward slashes",
|
||||||
|
input: "usr/bin/test",
|
||||||
|
expected: joinOSPath("usr", "bin", "test"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with consecutive separators",
|
||||||
|
input: "usr//bin///test",
|
||||||
|
expected: joinOSPath("usr", "bin", "test"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with . and ..",
|
||||||
|
input: "usr/./bin/../test",
|
||||||
|
expected: joinOSPath("usr", "test"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
validator := NewPathValidator(nil, true)
|
||||||
|
result := validator.SanitizePath(tc.input)
|
||||||
|
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("SanitizePath(%q) = %q, expected %q", tc.input, result, tc.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathValidator_ValidateFileName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid filename",
|
||||||
|
filename: "test.txt",
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty filename",
|
||||||
|
filename: "",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filename too long",
|
||||||
|
filename: string(make([]byte, 256)),
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filename with trailing whitespace",
|
||||||
|
filename: "test ",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add platform-specific tests
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cases = append(cases, []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "windows reserved name",
|
||||||
|
filename: "CON",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "windows invalid characters",
|
||||||
|
filename: "test<file",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
}...)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
validator := NewPathValidator(nil, true)
|
||||||
|
err := validator.ValidateFileName(tc.filename)
|
||||||
|
gotError := err != nil
|
||||||
|
|
||||||
|
if gotError != tc.wantError {
|
||||||
|
t.Errorf("ValidateFileName(%q) error = %v, wantError %v", tc.filename, err, tc.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathValidator_SafeJoin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
validator := NewPathValidator(nil, false) // Non-strict mode
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
base string
|
||||||
|
path string
|
||||||
|
wantError bool
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid join",
|
||||||
|
base: "/usr",
|
||||||
|
path: "bin/test",
|
||||||
|
wantError: false,
|
||||||
|
expected: joinOSPath("/usr", "bin", "test"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path traversal attempt",
|
||||||
|
base: "/usr",
|
||||||
|
path: "../../../etc/passwd",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty path",
|
||||||
|
base: "/usr",
|
||||||
|
path: "",
|
||||||
|
wantError: false,
|
||||||
|
expected: "/usr",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result, err := validator.SafeJoin(tc.base, tc.path)
|
||||||
|
|
||||||
|
if tc.wantError {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("SafeJoin(%q, %q) expected error", tc.base, tc.path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("SafeJoin(%q, %q) unexpected error: %v", tc.base, tc.path, err)
|
||||||
|
}
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("SafeJoin(%q, %q) = %q, expected %q", tc.base, tc.path, result, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathValidator_AllowedPaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
allowed := []string{"/tmp", "/var/tmp"}
|
||||||
|
validator := NewPathValidator(allowed, false) // Non-strict mode for absolute paths
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "allowed path",
|
||||||
|
path: "/tmp/test",
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disallowed path",
|
||||||
|
path: "/etc/passwd",
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subdirectory of allowed path",
|
||||||
|
path: "/var/tmp/subdir/file",
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := validator.ValidatePath(tc.path)
|
||||||
|
gotError := err != nil
|
||||||
|
|
||||||
|
if gotError != tc.wantError {
|
||||||
|
t.Errorf("ValidatePath(%q) error = %v, wantError %v", tc.path, err, tc.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for cross-platform path joining
|
||||||
|
func joinOSPath(parts ...string) string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := parts[0]
|
||||||
|
for _, part := range parts[1:] {
|
||||||
|
result += string(filepath.Separator) + part
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
242
scripts/build.sh
Executable file
242
scripts/build.sh
Executable file
|
|
@ -0,0 +1,242 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ZSVO Linux Build Script
|
||||||
|
# Supports building for Linux architectures
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BINARY_NAME="zsvo"
|
||||||
|
VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}
|
||||||
|
BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S')
|
||||||
|
LDFLAGS="-ldflags \"-X main.version=${VERSION} -X main.buildTime=${BUILD_TIME}\""
|
||||||
|
|
||||||
|
# Linux platforms
|
||||||
|
declare -A PLATFORMS=(
|
||||||
|
["amd64"]="x86_64-linux-gnu"
|
||||||
|
["arm64"]="aarch64-linux-gnu"
|
||||||
|
["386"]="i386-linux-gnu"
|
||||||
|
["arm"]="arm-linux-gnueabihf"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Functions
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_dependencies() {
|
||||||
|
log_info "Checking dependencies..."
|
||||||
|
|
||||||
|
if ! command -v go &> /dev/null; then
|
||||||
|
log_error "Go is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v git &> /dev/null; then
|
||||||
|
log_warning "Git is not installed (version detection may not work)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Dependencies check passed"
|
||||||
|
}
|
||||||
|
|
||||||
|
clean_build() {
|
||||||
|
log_info "Cleaning build artifacts..."
|
||||||
|
rm -rf bin/
|
||||||
|
rm -rf dist/
|
||||||
|
rm -rf release/
|
||||||
|
go clean -cache
|
||||||
|
log_success "Clean completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_platform() {
|
||||||
|
local arch=$1
|
||||||
|
local output_dir="dist/$arch"
|
||||||
|
local output_name="$BINARY_NAME-$arch"
|
||||||
|
|
||||||
|
log_info "Building $arch..."
|
||||||
|
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
|
||||||
|
GOOS=linux GOARCH=$arch go build $LDFLAGS -o "$output_dir/$output_name" .
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log_success "Built $arch successfully"
|
||||||
|
|
||||||
|
# Create checksum
|
||||||
|
cd "$output_dir"
|
||||||
|
if command -v sha256sum &> /dev/null; then
|
||||||
|
sha256sum "$output_name" > "$output_name.sha256"
|
||||||
|
elif command -v shasum &> /dev/null; then
|
||||||
|
shasum -a 256 "$output_name" > "$output_name.sha256"
|
||||||
|
fi
|
||||||
|
cd - > /dev/null
|
||||||
|
|
||||||
|
# Get binary size
|
||||||
|
local size=$(du -h "$output_dir/$output_name" | cut -f1)
|
||||||
|
log_info "Binary size: $size"
|
||||||
|
else
|
||||||
|
log_error "Failed to build $arch"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
build_all() {
|
||||||
|
log_info "Building ZSVO for Linux..."
|
||||||
|
log_info "Version: $VERSION"
|
||||||
|
log_info "Build Time: $BUILD_TIME"
|
||||||
|
|
||||||
|
local total=${#PLATFORMS[@]}
|
||||||
|
local current=0
|
||||||
|
|
||||||
|
for arch in "${!PLATFORMS[@]}"; do
|
||||||
|
((current++))
|
||||||
|
log_info "[$current/$total] Building $arch..."
|
||||||
|
|
||||||
|
if build_platform "$arch"; then
|
||||||
|
log_success "[$current/$total] ✓ $arch"
|
||||||
|
else
|
||||||
|
log_error "[$current/$total] ✗ $arch"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log_success "Build complete! Binaries are in dist/"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_specific() {
|
||||||
|
local target_arch=$1
|
||||||
|
|
||||||
|
if [ -z "$target_arch" ]; then
|
||||||
|
log_error "Usage: $0 build <arch>"
|
||||||
|
log_info "Example: $0 build amd64"
|
||||||
|
log_info "Available: ${!PLATFORMS[@]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${PLATFORMS[$target_arch]}" ]]; then
|
||||||
|
log_error "Unsupported architecture: $target_arch"
|
||||||
|
log_info "Available architectures:"
|
||||||
|
for arch in "${!PLATFORMS[@]}"; do
|
||||||
|
echo " - $arch"
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
build_platform "$target_arch"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_release() {
|
||||||
|
log_info "Creating release packages..."
|
||||||
|
|
||||||
|
mkdir -p release
|
||||||
|
|
||||||
|
for arch in "${!PLATFORMS[@]}"; do
|
||||||
|
local output_dir="dist/$arch"
|
||||||
|
local binary_name="$BINARY_NAME-$arch"
|
||||||
|
local release_name="$BINARY_NAME-$VERSION-$arch"
|
||||||
|
local release_dir="release/$release_name"
|
||||||
|
|
||||||
|
if [ -f "$output_dir/$binary_name" ]; then
|
||||||
|
log_info "Creating package for $arch..."
|
||||||
|
|
||||||
|
mkdir -p "$release_dir"
|
||||||
|
cp "$output_dir/$binary_name" "$release_dir/$BINARY_NAME"
|
||||||
|
cp "$output_dir/$binary_name.sha256" "$release_dir/" 2>/dev/null || true
|
||||||
|
cp README.md "$release_dir/" 2>/dev/null || true
|
||||||
|
cp LICENSE "$release_dir/" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create archive
|
||||||
|
cd release
|
||||||
|
tar -czf "$release_name.tar.gz" "$release_name"
|
||||||
|
log_success "Created $release_name.tar.gz"
|
||||||
|
cd - > /dev/null
|
||||||
|
else
|
||||||
|
log_warning "Binary not found for $arch: $output_dir/$binary_name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log_success "Release packages created in release/"
|
||||||
|
}
|
||||||
|
|
||||||
|
show_info() {
|
||||||
|
log_info "ZSVO Build Information:"
|
||||||
|
echo " Binary: $BINARY_NAME"
|
||||||
|
echo " Version: $VERSION"
|
||||||
|
echo " Build Time: $BUILD_TIME"
|
||||||
|
echo " Go Version: $(go version)"
|
||||||
|
echo ""
|
||||||
|
echo "Supported Architectures:"
|
||||||
|
for arch in "${!PLATFORMS[@]}"; do
|
||||||
|
echo " - $arch (${PLATFORMS[$arch]})"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main script
|
||||||
|
case "${1:-help}" in
|
||||||
|
"clean")
|
||||||
|
clean_build
|
||||||
|
;;
|
||||||
|
"build")
|
||||||
|
build_specific "$2"
|
||||||
|
;;
|
||||||
|
"all")
|
||||||
|
check_dependencies
|
||||||
|
clean_build
|
||||||
|
build_all
|
||||||
|
;;
|
||||||
|
"release")
|
||||||
|
check_dependencies
|
||||||
|
clean_build
|
||||||
|
build_all
|
||||||
|
create_release
|
||||||
|
;;
|
||||||
|
"info")
|
||||||
|
show_info
|
||||||
|
;;
|
||||||
|
"help"|*)
|
||||||
|
echo "ZSVO Linux Build Script"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 [command] [options]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " clean Clean build artifacts"
|
||||||
|
echo " build <arch> Build for specific architecture"
|
||||||
|
echo " all Build for all architectures"
|
||||||
|
echo " release Build and create release packages"
|
||||||
|
echo " info Show build information"
|
||||||
|
echo " help Show this help"
|
||||||
|
echo ""
|
||||||
|
echo "Architectures:"
|
||||||
|
for arch in "${!PLATFORMS[@]}"; do
|
||||||
|
echo " - $arch"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 all"
|
||||||
|
echo " $0 build amd64"
|
||||||
|
echo " $0 build arm64"
|
||||||
|
echo " $0 release"
|
||||||
|
echo ""
|
||||||
|
echo "Environment Variables:"
|
||||||
|
echo " VERSION Override version (default: git describe)"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
142
scripts/docker.sh
Executable file
142
scripts/docker.sh
Executable file
|
|
@ -0,0 +1,142 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# ZSVO Docker Build Script
|
||||||
|
# Linux-only Docker builds
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
IMAGE_NAME="zsvo"
|
||||||
|
VERSION=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}
|
||||||
|
REGISTRY=${REGISTRY:-"localhost:5000"}
|
||||||
|
|
||||||
|
# Functions
|
||||||
|
log_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_docker() {
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
log_error "Docker is not installed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker info &> /dev/null; then
|
||||||
|
log_error "Docker daemon is not running"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Docker check passed"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_image() {
|
||||||
|
local tag=${1:-$IMAGE_NAME:$VERSION}
|
||||||
|
|
||||||
|
log_info "Building Docker image..."
|
||||||
|
docker build -t $tag .
|
||||||
|
log_success "Built Docker image: $tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_container() {
|
||||||
|
local tag=${1:-$IMAGE_NAME:$VERSION}
|
||||||
|
local command=${2:-"--help"}
|
||||||
|
|
||||||
|
log_info "Running container: $tag"
|
||||||
|
log_info "Command: zsvo $command"
|
||||||
|
docker run --rm -it $tag zsvo $command
|
||||||
|
}
|
||||||
|
|
||||||
|
push_image() {
|
||||||
|
local tag=${1:-$IMAGE_NAME:$VERSION}
|
||||||
|
local remote=${2:-$REGISTRY}
|
||||||
|
|
||||||
|
log_info "Pushing image: $tag"
|
||||||
|
docker tag $tag $remote/$tag
|
||||||
|
docker push $remote/$tag
|
||||||
|
log_success "Image pushed: $remote/$tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
clean_images() {
|
||||||
|
log_info "Cleaning Docker images..."
|
||||||
|
|
||||||
|
docker images $IMAGE_NAME --format "table {{.Repository}}:{{.Tag}}" | grep -v REPOSITORY | while read line; do
|
||||||
|
if [ -n "$line" ]; then
|
||||||
|
docker rmi $line 2>/dev/null || true
|
||||||
|
log_info "Removed: $line"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
log_success "Docker cleanup complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
show_info() {
|
||||||
|
log_info "ZSVO Docker Information:"
|
||||||
|
echo " Image: $IMAGE_NAME"
|
||||||
|
echo " Version: $VERSION"
|
||||||
|
echo " Registry: $REGISTRY"
|
||||||
|
echo ""
|
||||||
|
echo "Available images:"
|
||||||
|
docker images $IMAGE_NAME 2>/dev/null | head -10 || echo " No images found"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main script
|
||||||
|
case "${1:-help}" in
|
||||||
|
"build")
|
||||||
|
build_image "$2"
|
||||||
|
;;
|
||||||
|
"run")
|
||||||
|
run_container "$2" "$3"
|
||||||
|
;;
|
||||||
|
"push")
|
||||||
|
push_image "$2" "$3"
|
||||||
|
;;
|
||||||
|
"clean")
|
||||||
|
clean_images
|
||||||
|
;;
|
||||||
|
"info")
|
||||||
|
show_info
|
||||||
|
;;
|
||||||
|
"help"|*)
|
||||||
|
echo "ZSVO Docker Build Script"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 [command] [options]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " build [tag] Build Docker image"
|
||||||
|
echo " run [tag] [command] Run container"
|
||||||
|
echo " push [tag] [registry] Push image to registry"
|
||||||
|
echo " clean Clean Docker images"
|
||||||
|
echo " info Show Docker information"
|
||||||
|
echo " help Show this help"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 build"
|
||||||
|
echo " $0 build zsvo:latest"
|
||||||
|
echo " $0 run zsvo:latest --help"
|
||||||
|
echo " $0 push zsvo:latest my-registry.com"
|
||||||
|
echo ""
|
||||||
|
echo "Environment Variables:"
|
||||||
|
echo " VERSION Override version"
|
||||||
|
echo " REGISTRY Docker registry"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
BIN
zsvo
BIN
zsvo
Binary file not shown.
40
zsvo-docker.sh
Executable file
40
zsvo-docker.sh
Executable file
|
|
@ -0,0 +1,40 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "🚀 Запускаем ZSVO в Docker..."
|
||||||
|
|
||||||
|
# Проверяем что Docker запущен
|
||||||
|
if ! docker ps > /dev/null 2>&1; then
|
||||||
|
echo "❌ Docker не запущен. Запускаем Docker Desktop..."
|
||||||
|
open -a Docker
|
||||||
|
echo "⏳ Ждем запуска Docker..."
|
||||||
|
sleep 10
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Запускаем контейнер
|
||||||
|
docker run -it --rm \
|
||||||
|
-v $(pwd):/workspace \
|
||||||
|
-w /workspace \
|
||||||
|
--name zsvo-test \
|
||||||
|
golang:1.23 bash -c "
|
||||||
|
echo '=== УСТАНАВЛИВАЕМ ЗАВИСИМОСТИ ===' &&
|
||||||
|
apt-get update -qq &&
|
||||||
|
apt-get install -y -qq build-essential git curl wget cmake meson pkg-config python3 tar xz-utils &&
|
||||||
|
|
||||||
|
echo '=== СОБИРАЕМ ZSVO ===' &&
|
||||||
|
go build -o zsvo . &&
|
||||||
|
|
||||||
|
echo '=== ГОТОВО! ТЕРМИНАЛ ZSVO ===' &&
|
||||||
|
echo 'Доступные команды:' &&
|
||||||
|
echo ' ./zsvo doctor' &&
|
||||||
|
echo ' ./zsvo install htop --dry-run' &&
|
||||||
|
echo ' ./zsvo install neovim --dry-run' &&
|
||||||
|
echo ' ./zsvo search python' &&
|
||||||
|
echo ' ./zsvo lang ru' &&
|
||||||
|
echo ' ./zsvo cache info' &&
|
||||||
|
echo '' &&
|
||||||
|
echo 'Для выхода: exit' &&
|
||||||
|
echo '' &&
|
||||||
|
bash
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "✅ ZSVO Docker контейнер завершен"
|
||||||
Loading…
Reference in a new issue