diff --git a/.gitignore b/.gitignore index 28053ae..49cfc83 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ zsvo pkg-manager # Build artifacts +dist/ +release/ +bin/ *.exe *.dll *.so @@ -25,14 +28,6 @@ Thumbs.db # Cache directories .cache/ -tmp/ -temp/ - -# Test coverage -coverage.txt -*.coverprofile - -# Dependency directories vendor/ node_modules/ yay/ @@ -48,13 +43,28 @@ yay/ *.backup *.orig -# Docker and deployment scripts -zsvo-docker.sh -*.sh -deploy/ -docker-compose.yml +# Test coverage +coverage.txt +*.coverprofile +coverage.html + +# Go specific +*.test +*.prof # Work directories /tmp/pkg-work/ /tmp/zsvo-cache/ *.pkg.tar.zst +work/ + +# Docker +.dockerignore + +# Scripts output +scripts/*.log +scripts/*.tmp + +# Allow scripts directory +!scripts/ +!scripts/*.sh diff --git a/DEPENDENCY_RESOLUTION.md b/DEPENDENCY_RESOLUTION.md new file mode 100644 index 0000000..661bd98 --- /dev/null +++ b/DEPENDENCY_RESOLUTION.md @@ -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 +- **Надежность** - транзакционная установка с откатом +- **Безопасность** - валидация путей и проверка целостности +- **Гибкость** - поддержка разных источников пакетов +- **Производительность** - кэширование и параллельная обработка + +Теперь твой пакетный менеджер может самостоятельно управлять зависимостями без необходимости в сторонних инструментах! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eeac306 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a43e815 --- /dev/null +++ b/Makefile @@ -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)" diff --git a/README_RELEASE.md b/README_RELEASE.md new file mode 100644 index 0000000..710ecc1 --- /dev/null +++ b/README_RELEASE.md @@ -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. diff --git a/cmd/install.go b/cmd/install.go index 536a320..d211922 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -35,6 +35,7 @@ var InstallCmd = &cobra.Command{ } autoSource, _ := cmd.Flags().GetBool("auto-source") autoBuildDeps, _ := cmd.Flags().GetBool("auto-build-deps") + autoResolveDeps, _ := cmd.Flags().GetBool("auto-resolve-deps") dryRun, _ := cmd.Flags().GetBool("dry-run") if dryRun { @@ -45,6 +46,7 @@ var InstallCmd = &cobra.Command{ status.PrintInfo(fmt.Sprintf("Work directory: %s", workDir)) status.PrintInfo(fmt.Sprintf("Auto-source: %t", autoSource)) status.PrintInfo(fmt.Sprintf("Auto-build-deps: %t", autoBuildDeps)) + status.PrintInfo(fmt.Sprintf("Auto-resolve-deps: %t", autoResolveDeps)) status.PrintFooter() } @@ -121,7 +123,22 @@ var InstallCmd = &cobra.Command{ 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) } @@ -135,7 +152,8 @@ func init() { 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-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) { @@ -457,6 +475,18 @@ func inferMissingBuildDeps(err error) []string { 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 { return nil } @@ -493,6 +523,8 @@ func mapToolToSourcePackage(tool string) string { return "binutils" case "xzcat": return "xz-utils" + case "git": + return "git" } if !simplePkgNamePattern.MatchString(tool) { diff --git a/pkg-manager b/pkg-manager deleted file mode 100755 index 112a935..0000000 Binary files a/pkg-manager and /dev/null differ diff --git a/pkg/builder/builder.go b/pkg/builder/builder.go index ad1f6ad..af9cbfb 100644 --- a/pkg/builder/builder.go +++ b/pkg/builder/builder.go @@ -8,6 +8,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "zsvo/pkg/fetcher" "zsvo/pkg/packager" @@ -20,6 +21,7 @@ type Builder struct { quiet bool progressCallback func(BuildProgress) envOverrides map[string]string + callbackMutex sync.RWMutex } // BuildProgress represents build progress state. @@ -378,6 +380,8 @@ func (b *Builder) SetQuiet(quiet bool) { // SetProgressCallback sets callback for build progress updates. func (b *Builder) SetProgressCallback(callback func(BuildProgress)) { + b.callbackMutex.Lock() + defer b.callbackMutex.Unlock() 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) { - if b.progressCallback == nil { + b.callbackMutex.RLock() + callback := b.progressCallback + b.callbackMutex.RUnlock() + + if callback == nil { return } - b.progressCallback(BuildProgress{ + callback(BuildProgress{ Step: step, Total: total, Message: message, diff --git a/pkg/deps/deps.go b/pkg/deps/deps.go index c9b3914..0d49b20 100644 --- a/pkg/deps/deps.go +++ b/pkg/deps/deps.go @@ -298,20 +298,23 @@ func compareVersionPart(a, b string) int { i, j := 0, 0 for i < len(a) || j < len(b) { - // Tilde sorts before everything, including end of string. - if i < len(a) && a[i] == '~' || j < len(b) && b[j] == '~' { - switch { - case i < len(a) && a[i] == '~' && j < len(b) && b[j] == '~': - i++ - j++ - continue - case i < len(a) && a[i] == '~': - return -1 - default: - return 1 - } + // Handle tilde comparison - tilde sorts before everything + aHasTilde := i < len(a) && a[i] == '~' + bHasTilde := j < len(b) && b[j] == '~' + + switch { + case aHasTilde && bHasTilde: + // Both have tilde, compare the rest + i++ + j++ + continue + case aHasTilde: + return -1 // a has tilde, sorts before b + case bHasTilde: + return 1 // b has tilde, sorts before a } + // Skip separators for i < len(a) && isVersionSeparator(a[i]) { i++ } diff --git a/pkg/deps/deps_test.go b/pkg/deps/deps_test.go index 788badd..e91776b 100644 --- a/pkg/deps/deps_test.go +++ b/pkg/deps/deps_test.go @@ -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 { switch { case v < 0: diff --git a/pkg/deps/resolver.go b/pkg/deps/resolver.go new file mode 100644 index 0000000..e86d143 --- /dev/null +++ b/pkg/deps/resolver.go @@ -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 +} diff --git a/pkg/deps/resolver_test.go b/pkg/deps/resolver_test.go new file mode 100644 index 0000000..c723b69 --- /dev/null +++ b/pkg/deps/resolver_test.go @@ -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]) + } + } +} diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go new file mode 100644 index 0000000..2b70267 --- /dev/null +++ b/pkg/errors/errors.go @@ -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, + }) +} diff --git a/pkg/errors/errors_test.go b/pkg/errors/errors_test.go new file mode 100644 index 0000000..86ef4a9 --- /dev/null +++ b/pkg/errors/errors_test.go @@ -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'") + } +} diff --git a/pkg/installer/auto_resolve_test.go b/pkg/installer/auto_resolve_test.go new file mode 100644 index 0000000..df9a029 --- /dev/null +++ b/pkg/installer/auto_resolve_test.go @@ -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") + } +} diff --git a/pkg/installer/installer.go b/pkg/installer/installer.go index 00f930d..42314ee 100644 --- a/pkg/installer/installer.go +++ b/pkg/installer/installer.go @@ -11,6 +11,7 @@ import ( "syscall" "zsvo/pkg/deps" + "zsvo/pkg/errors" "zsvo/pkg/packager" "zsvo/pkg/types" ) @@ -19,6 +20,8 @@ import ( type Installer struct { rootDir string pkgDB string + resolver *deps.DependencyResolver + repo *InstallerPackageRepository } // RemoveOptions controls package removal behavior. @@ -27,10 +30,16 @@ type RemoveOptions struct { } func NewInstaller(rootDir string) *Installer { - return &Installer{ + inst := &Installer{ rootDir: rootDir, 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. @@ -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. func (i *Installer) Upgrade(packagePaths []string) error { 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) { + 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-") if err != nil { 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 { 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 { 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)) } } + + // 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 { @@ -821,6 +880,9 @@ func removePathIfExists(path string) error { } func validateSimpleDependencies(rawDeps []string) error { + if rawDeps == nil { + return nil // Empty dependencies are valid + } if _, err := deps.ParseRequirements(rawDeps); err != nil { return err } @@ -1309,6 +1371,347 @@ func (i *Installer) GetPackageSize(packageName string) (int64, error) { 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 { path string info *types.PkgInfo diff --git a/pkg/installer/repository_adapter.go b/pkg/installer/repository_adapter.go new file mode 100644 index 0000000..2c62179 --- /dev/null +++ b/pkg/installer/repository_adapter.go @@ -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 +} diff --git a/pkg/packager/packager.go b/pkg/packager/packager.go index 6e16462..815d33d 100644 --- a/pkg/packager/packager.go +++ b/pkg/packager/packager.go @@ -132,20 +132,39 @@ func (p *Packager) writePkgInfo(path string, pkgInfo *types.PkgInfo) error { func (p *Packager) createArchive(sourceDir, archivePath string) error { outFile, err := os.Create(archivePath) 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) 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) - 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 { return walkErr } @@ -155,7 +174,7 @@ func (p *Packager) createArchive(sourceDir, archivePath string) error { relPath, err := filepath.Rel(sourceDir, path) if err != nil { - return err + return fmt.Errorf("failed to get relative path for %s: %w", path, err) } relPath = filepath.ToSlash(relPath) @@ -163,18 +182,18 @@ func (p *Packager) createArchive(sourceDir, archivePath string) error { if info.Mode()&os.ModeSymlink != 0 { linkTarget, err = os.Readlink(path) if err != nil { - return err + return fmt.Errorf("failed to read symlink %s: %w", path, err) } } header, err := tar.FileInfoHeader(info, linkTarget) if err != nil { - return err + return fmt.Errorf("failed to create tar header for %s: %w", path, err) } header.Name = relPath 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() { @@ -183,18 +202,24 @@ func (p *Packager) createArchive(sourceDir, archivePath string) error { file, err := os.Open(path) if err != nil { - return err - } - if _, err := io.Copy(tarWriter, file); err != nil { - file.Close() - return err - } - if err := file.Close(); 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 { + return fmt.Errorf("failed to copy file %s to archive: %w", path, err) + } return nil }) + + if walkErr != nil { + return walkErr + } + if closeErr != nil { + return closeErr + } + + return nil } // Extract extracts a package archive diff --git a/pkg/recipe/recipe.go b/pkg/recipe/recipe.go index ac1df16..b58d1c8 100644 --- a/pkg/recipe/recipe.go +++ b/pkg/recipe/recipe.go @@ -79,15 +79,27 @@ func ParseRecipeFromReader(r io.Reader) (*Recipe, error) { switch section { case "build": + if item == "" { + return nil, fmt.Errorf("build command cannot be empty at line %d", lineNo) + } rcp.Build = append(rcp.Build, item) case "install": + if item == "" { + return nil, fmt.Errorf("install command cannot be empty at line %d", lineNo) + } rcp.Install = append(rcp.Install, item) case "deps": + if item == "" { + return nil, fmt.Errorf("dependency cannot be empty at line %d", lineNo) + } rcp.Deps = append(rcp.Deps, item) case "source": if sourceSubsection != "patches" { 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) default: return nil, fmt.Errorf("list item outside section at line %d", lineNo) diff --git a/pkg/security/path_validator.go b/pkg/security/path_validator.go new file mode 100644 index 0000000..a794506 --- /dev/null +++ b/pkg/security/path_validator.go @@ -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 +} diff --git a/pkg/security/path_validator_test.go b/pkg/security/path_validator_test.go new file mode 100644 index 0000000..5b63827 --- /dev/null +++ b/pkg/security/path_validator_test.go @@ -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/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 " + 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 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 diff --git a/scripts/docker.sh b/scripts/docker.sh new file mode 100755 index 0000000..209ff1e --- /dev/null +++ b/scripts/docker.sh @@ -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 diff --git a/zsvo b/zsvo deleted file mode 100755 index 913e8a3..0000000 Binary files a/zsvo and /dev/null differ diff --git a/zsvo-docker.sh b/zsvo-docker.sh new file mode 100755 index 0000000..4030417 --- /dev/null +++ b/zsvo-docker.sh @@ -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 контейнер завершен"