рагшцр

This commit is contained in:
itexpert228 2026-03-13 15:18:31 +03:00
parent a4dcdddd4a
commit 227ae46d6c
No known key found for this signature in database
25 changed files with 3114 additions and 48 deletions

36
.gitignore vendored
View file

@ -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
View 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
View 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
View 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
View 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.

View file

@ -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) {

Binary file not shown.

View file

@ -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,

View file

@ -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++
} }

View file

@ -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
View 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
View 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
View 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
View 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'")
}
}

View 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")
}
}

View 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

View 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
}

View file

@ -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

View file

@ -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)

View 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
}

View 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
View 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
View 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

Binary file not shown.

40
zsvo-docker.sh Executable file
View 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 контейнер завершен"