кал много кала......просто огромное количество кала....

This commit is contained in:
itexpert228 2026-03-13 00:37:11 +03:00
parent e351ee62d1
commit 8db46a6b45
No known key found for this signature in database
25 changed files with 2634 additions and 695 deletions

1
.gitignore vendored
View file

@ -35,6 +35,7 @@ coverage.txt
# Dependency directories
vendor/
node_modules/
yay/
# Environment files
.env

197
README.md
View file

@ -1,159 +1,82 @@
# Package Manager
# ZSVO Package Manager
A minimal source-based package manager for custom Linux distributions, inspired by Arch Linux and Gentoo.
`zsvo` — source-based package manager.
## Project Structure
Pipeline:
```
zsvo/
├── go.mod # Go module definition
├── go.sum # Go dependencies
├── main.go # CLI entry point
├── cmd/ # CLI commands
│ ├── build.go # pkg build command
│ ├── install.go # pkg install command
│ ├── remove.go # pkg remove command
│ ├── list.go # pkg list command
│ └── info.go # pkg info command
├── pkg/ # Core packages
│ ├── recipe/ # Recipe parsing and validation
│ │ └── recipe.go
│ ├── fetcher/ # Source downloading and extraction
│ │ └── fetcher.go
│ ├── builder/ # Package building
│ │ └── builder.go
│ ├── packager/ # Package creation
│ │ └── packager.go
│ └── installer/ # Package installation/removal
│ └── installer.go
└── recipes/ # Example recipes
└── zlib.toml
```
`recipe -> download source -> extract -> build -> install into DESTDIR -> create package -> install package`
## Modules
### Recipe Module (`pkg/recipe/`)
- Parses TOML recipe files
- Validates recipe structure
- Provides package naming utilities
- Defines `Recipe`, `Source`, `Build`, and `PkgInfo` structs
### Fetcher Module (`pkg/fetcher/`)
- Downloads sources with checksum verification
- Extracts various archive formats
- Handles patch application
- Implements caching for downloaded sources
- Supports concurrent downloads
### Builder Module (`pkg/builder/`)
- Manages build process from recipe
- Sets up build environment
- Executes build commands
- Handles source directory management
- Provides build information and file listing
### Packager Module (`pkg/packager/`)
- Creates compressed package archives
- Generates package metadata (.pkginfo)
- Extracts packages for installation
- Verifies package integrity
- Lists package contents
### Installer Module (`pkg/installer/`)
- Installs packages to root filesystem
- Manages package database
- Handles dependency checking
- Removes packages and cleans up
- Provides package information queries
## Usage
### Building a Package
## Commands
```bash
# Build from recipe
pkg build recipes/zlib.toml
# Build package from recipe
zsvo build recipes/zlib.yaml
# Build with custom work directory
pkg build -w /tmp/build zlib.toml
```
### Installing a Package
```bash
# Install package
pkg install /path/to/package.pkg.tar.zst
# Install package built by zsvo
zsvo install /path/to/name-version.pkg.tar.zst
# Install to custom root
pkg install -r /mnt/root package.pkg.tar.zst
zsvo install --root /mnt/root /path/to/name-version.pkg.tar.zst
# Remove package(s)
zsvo remove bash
zsvo remove -c libfoo # cascade
zsvo remove -n bash # dry-run
# List installed / orphan packages
zsvo list
zsvo list --orphans
# Show package metadata
zsvo info bash
```
### Managing Packages
## Recipe Format (YAML)
```bash
# List installed packages
pkg list
```yaml
name: bash
version: "5.2"
# Show package information
pkg info zlib
description: GNU Bourne Again SHell
# Remove package
pkg remove zlib
source:
url: https://ftp.gnu.org/gnu/bash/bash-5.2.tar.gz
sha256: examplehash
build:
- ./configure --prefix=/usr
- make -j$(nproc)
install:
- make DESTDIR={{pkgdir}} install
deps:
- glibc
- readline
```
## Recipe Format
Debian upstream source mode:
Recipes are written in TOML format:
```yaml
name: bash
version: "5.2"
```toml
name = "package-name"
version = "1.0.0"
description = "Package description"
[source]
url = "https://example.com/package-1.0.0.tar.gz"
sha256 = "sha256-hash-of-source"
patches = ["patch1.diff", "patch2.diff"]
[build]
commands = [
"./configure --prefix=/usr",
"make -j$(nproc)",
"make DESTDIR={{pkgdir}} install"
]
env = [
"CFLAGS=-O2"
]
[dependencies]
# List of package dependencies
source:
debian_dsc: https://deb.debian.org/debian/pool/main/b/bash/bash_5.2.37-2.dsc
# optional: sha256 of the .dsc file itself
# sha256: <dsc-sha256>
```
## Package Format
Notes:
- `deps` are simple package names (no version solver yet).
- `{{pkgdir}}` (and `${pkgdir}`) points to staging DESTDIR.
- If `source.debian_dsc` (or `.dsc` URL) is used, zsvo downloads only `*.orig*.tar.*` archives from that source package and ignores Debian patch archives (`debian.tar.*` / `diff.gz`).
- Package metadata is stored in `.zsvo.yml` (not `.PKGINFO`).
Packages are created as `name-version.pkg.tar.zst` archives containing:
- Installed files
- `.pkginfo` metadata file
## Architecture
## Package Database
Installed packages are tracked in `/var/lib/pkgdb/<pkgname>/` with:
- `.pkginfo` file containing package metadata
- File lists and installation information
## Dependencies
- `github.com/BurntSushi/toml` - TOML parsing
- `github.com/mholt/archiver/v4` - Archive handling
- `github.com/spf13/cobra` - CLI framework
- `github.com/spf13/viper` - Configuration management
- `golang.org/x/sync` - Concurrent operations
## Philosophy
This package manager follows the simplicity philosophy of Arch Linux tools:
- Minimal dependencies
- Clear separation of concerns
- Production-ready code
- Source-based package management
- Simple, understandable implementation
- `pkg/recipe` — YAML recipe parser.
- `pkg/fetcher` — download + checksum + extract + patch apply.
- `pkg/builder` — build/install pipeline in isolated workdir.
- `pkg/packager` — create/read `name-version.pkg.tar.zst` from staging.
- `pkg/installer` — install/remove packages into `--root` with file safety checks and rollback.

View file

@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"path/filepath"
"github.com/spf13/cobra"
"zsvo/pkg/builder"
@ -17,7 +18,7 @@ var BuildCmd = &cobra.Command{
recipePath := args[0]
// Parse recipe
recipe, err := recipe.ParseRecipe(recipePath)
rcp, err := recipe.ParseRecipe(recipePath)
if err != nil {
return fmt.Errorf("failed to parse recipe: %w", err)
}
@ -31,12 +32,13 @@ var BuildCmd = &cobra.Command{
b := builder.NewBuilder(workDir)
// Build package
fmt.Printf("Building package %s...\n", recipe.GetPackageName())
if err := b.Build(recipe); err != nil {
fmt.Printf("Building package %s...\n", rcp.GetPackageName())
if err := b.Build(rcp); err != nil {
return fmt.Errorf("failed to build package: %w", err)
}
fmt.Printf("Package %s built successfully\n", recipe.GetPackageName())
fmt.Printf("Package %s built successfully\n", rcp.GetPackageName())
fmt.Printf("Package file: %s\n", filepath.Join(rcp.GetPackageDir(workDir), rcp.GetPackageFileName()))
return nil
},
}

View file

@ -8,13 +8,11 @@ import (
)
var InstallCmd = &cobra.Command{
Use: "install <package>",
Short: "Install a package",
Long: `Install a package from a package file`,
Args: cobra.ExactArgs(1),
Use: "install <package> [package...]",
Short: "Install package(s)",
Long: `Install one or more packages from package files`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
packagePath := args[0]
// Create installer
rootDir, _ := cmd.Flags().GetString("root")
if rootDir == "" {
@ -23,13 +21,17 @@ var InstallCmd = &cobra.Command{
i := installer.NewInstaller(rootDir)
// Install package
fmt.Printf("Installing package from %s...\n", packagePath)
if err := i.Install(packagePath); err != nil {
return fmt.Errorf("failed to install package: %w", err)
// Install packages
if len(args) == 1 {
fmt.Printf("Installing package from %s...\n", args[0])
} else {
fmt.Printf("Installing %d packages...\n", len(args))
}
if err := i.InstallMany(args); err != nil {
return fmt.Errorf("failed to install packages: %w", err)
}
fmt.Printf("Package installed successfully\n")
fmt.Printf("Package installation completed successfully\n")
return nil
},
}

View file

@ -19,19 +19,35 @@ var ListCmd = &cobra.Command{
}
i := installer.NewInstaller(rootDir)
orphansOnly, _ := cmd.Flags().GetBool("orphans")
// List installed packages
packages, err := i.ListInstalled()
var (
packages []string
err error
)
if orphansOnly {
packages, err = i.ListOrphans()
} else {
packages, err = i.ListInstalled()
}
if err != nil {
return fmt.Errorf("failed to list packages: %w", err)
}
if len(packages) == 0 {
if orphansOnly {
fmt.Println("No orphan packages")
} else {
fmt.Println("No packages installed")
}
return nil
}
if orphansOnly {
fmt.Println("Orphan packages:")
} else {
fmt.Println("Installed packages:")
}
for _, pkg := range packages {
fmt.Printf(" %s\n", pkg)
}
@ -41,4 +57,5 @@ var ListCmd = &cobra.Command{
func init() {
ListCmd.Flags().StringP("root", "r", "/", "Root directory for installation")
ListCmd.Flags().BoolP("orphans", "o", false, "List only orphan packages")
}

View file

@ -8,13 +8,11 @@ import (
)
var RemoveCmd = &cobra.Command{
Use: "remove <package>",
Short: "Remove a package",
Long: `Remove an installed package`,
Args: cobra.ExactArgs(1),
Use: "remove <package> [package...]",
Short: "Remove package(s)",
Long: `Remove one or more installed packages`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
packageName := args[0]
// Create installer
rootDir, _ := cmd.Flags().GetString("root")
if rootDir == "" {
@ -22,18 +20,45 @@ var RemoveCmd = &cobra.Command{
}
i := installer.NewInstaller(rootDir)
// Remove package
fmt.Printf("Removing package %s...\n", packageName)
if err := i.Remove(packageName); err != nil {
return fmt.Errorf("failed to remove package: %w", err)
cascade, _ := cmd.Flags().GetBool("cascade")
dryRun, _ := cmd.Flags().GetBool("dry-run")
options := installer.RemoveOptions{
Cascade: cascade,
}
fmt.Printf("Package %s removed successfully\n", packageName)
if dryRun {
plan, err := i.PlanRemove(args, options)
if err != nil {
return fmt.Errorf("failed to calculate removal plan: %w", err)
}
if len(plan) == 0 {
fmt.Println("Nothing to remove")
return nil
}
fmt.Printf("Planned removal (%d package(s)):\n", len(plan))
for _, pkgName := range plan {
fmt.Printf(" %s\n", pkgName)
}
return nil
}
removed, err := i.RemoveMany(args, options)
if err != nil {
return fmt.Errorf("failed to remove packages: %w", err)
}
fmt.Printf("Removed %d package(s):\n", len(removed))
for _, pkgName := range removed {
fmt.Printf(" %s\n", pkgName)
}
return nil
},
}
func init() {
RemoveCmd.Flags().StringP("root", "r", "/", "Root directory for installation")
RemoveCmd.Flags().BoolP("cascade", "c", false, "Remove dependent packages that become broken")
RemoveCmd.Flags().BoolP("dry-run", "n", false, "Show packages that would be removed without applying changes")
}

35
cmd/upgrade.go Normal file
View file

@ -0,0 +1,35 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"zsvo/pkg/installer"
)
var UpgradeCmd = &cobra.Command{
Use: "upgrade <package> [package...]",
Short: "Upgrade package(s)",
Long: `Upgrade one or more packages from local package files`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
rootDir, _ := cmd.Flags().GetString("root")
if rootDir == "" {
rootDir = "/"
}
i := installer.NewInstaller(rootDir)
fmt.Printf("Upgrading %d package(s)...\n", len(args))
if err := i.Upgrade(args); err != nil {
return fmt.Errorf("failed to upgrade packages: %w", err)
}
fmt.Printf("Package upgrade completed successfully\n")
return nil
},
}
func init() {
UpgradeCmd.Flags().StringP("root", "r", "/", "Root directory for installation")
}

1
go.mod
View file

@ -3,7 +3,6 @@ module zsvo
go 1.23.0
require (
github.com/BurntSushi/toml v1.3.2
github.com/mholt/archiver/v3 v3.5.1
github.com/spf13/cobra v1.8.1
golang.org/x/sync v0.12.0

3
go.sum
View file

@ -1,5 +1,3 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/andybalholm/brotli v1.0.1 h1:KqhlKozYbRtJvsPrrEeXcO+N2l6NYT5A2QAFmSULpEc=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@ -38,4 +36,3 @@ golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -9,14 +9,15 @@ import (
)
var rootCmd = &cobra.Command{
Use: "pkg",
Use: "zsvo",
Short: "A simple source-based package manager",
Long: `A minimal package manager for custom Linux distributions based on LFS
Available commands:
build Build a package from recipe
install Install a package from package file
remove Remove an installed package
install Install package(s) from package files
upgrade Upgrade package(s) from package files
remove Remove installed package(s)
list List installed packages
info Show package information
help Show help for a command
@ -27,6 +28,7 @@ func init() {
// Register all commands
rootCmd.AddCommand(cmd.BuildCmd)
rootCmd.AddCommand(cmd.InstallCmd)
rootCmd.AddCommand(cmd.UpgradeCmd)
rootCmd.AddCommand(cmd.RemoveCmd)
rootCmd.AddCommand(cmd.ListCmd)
rootCmd.AddCommand(cmd.InfoCmd)

View file

@ -8,8 +8,9 @@ import (
"runtime"
"strings"
"zsvo/pkg/recipe"
"zsvo/pkg/fetcher"
"zsvo/pkg/packager"
"zsvo/pkg/recipe"
)
// Builder handles building packages from recipes
@ -26,6 +27,10 @@ func NewBuilder(workDir string) *Builder {
// Build builds a package from a recipe
func (b *Builder) Build(recipe *recipe.Recipe) error {
if recipe == nil {
return fmt.Errorf("recipe cannot be nil")
}
// Create working directories
sourceDir := recipe.GetSourceDir(b.workDir)
stagingDir := recipe.GetStagingDir(b.workDir)
@ -51,10 +56,16 @@ func (b *Builder) Build(recipe *recipe.Recipe) error {
}
// Package files
if err := b.packageFiles(recipe, sourceDir, stagingDir, packageDir); err != nil {
if err := b.packageFiles(recipe, sourceDir, stagingDir); err != nil {
return fmt.Errorf("failed to package files: %w", err)
}
// Create package archive
p := packager.NewPackager(b.workDir)
if err := p.Package(recipe); err != nil {
return fmt.Errorf("failed to create package archive: %w", err)
}
return nil
}
@ -77,11 +88,25 @@ func (b *Builder) prepareDirectories(sourceDir, stagingDir, packageDir string) e
// downloadAndExtract downloads and extracts source
func (b *Builder) downloadAndExtract(recipe *recipe.Recipe) error {
sourceDir := recipe.GetSourceDir(b.workDir)
if err := os.RemoveAll(sourceDir); err != nil {
return fmt.Errorf("failed to clean source directory: %w", err)
}
if err := os.MkdirAll(sourceDir, 0755); err != nil {
return fmt.Errorf("failed to recreate source directory: %w", err)
}
f := fetcher.NewFetcher(filepath.Join(b.workDir, "cache"))
sourceURL := recipe.Source.URL
if recipe.Source.DebianDSC != "" {
sourceURL = recipe.Source.DebianDSC
}
return f.DownloadAndExtract(
recipe.Source.URL,
sourceURL,
recipe.Source.Sha256,
recipe.GetSourceDir(b.workDir),
sourceDir,
)
}
@ -91,8 +116,20 @@ func (b *Builder) applyPatches(recipe *recipe.Recipe, sourceDir string) error {
return nil
}
patches := make([]string, 0, len(recipe.Source.Patches))
for _, patchPath := range recipe.Source.Patches {
if patchPath == "" {
return fmt.Errorf("patch path cannot be empty")
}
if !filepath.IsAbs(patchPath) && recipe.Dir != "" {
patchPath = filepath.Join(recipe.Dir, patchPath)
}
patches = append(patches, patchPath)
}
f := fetcher.NewFetcher(filepath.Join(b.workDir, "cache"))
return f.ApplyPatches(sourceDir, recipe.Source.Patches)
return f.ApplyPatches(sourceDir, patches)
}
// executeBuild executes build commands
@ -104,12 +141,12 @@ func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir stri
}
// Set up environment
env := b.buildEnvironment(recipe, stagingDir)
env := b.buildEnvironment(stagingDir)
// Execute build commands
for i, cmd := range recipe.Build.Commands {
for i, cmd := range recipe.Build {
// Substitute variables in command
cmd = b.substituteVariables(cmd, recipe, sourceDir, stagingDir)
cmd = b.substituteVariables(cmd, sourceDir, stagingDir)
if err := b.executeCommand(srcPath, cmd, env); err != nil {
return fmt.Errorf("build command %d failed: %w", i+1, err)
@ -144,7 +181,7 @@ func (b *Builder) findSourceDirectory(sourceDir string) (string, error) {
}
// buildEnvironment builds the environment for build commands
func (b *Builder) buildEnvironment(recipe *recipe.Recipe, stagingDir string) []string {
func (b *Builder) buildEnvironment(stagingDir string) []string {
env := os.Environ()
// Add standard build environment variables
@ -152,11 +189,6 @@ func (b *Builder) buildEnvironment(recipe *recipe.Recipe, stagingDir string) []s
env = append(env, fmt.Sprintf("PREFIX=/usr"))
env = append(env, fmt.Sprintf("PKGDIR=%s", stagingDir))
// Add recipe-specific environment variables
for _, envVar := range recipe.Build.Env {
env = append(env, envVar)
}
// Add parallel build variable
env = append(env, fmt.Sprintf("MAKEFLAGS=-j%d", runtime.NumCPU()))
@ -165,13 +197,11 @@ func (b *Builder) buildEnvironment(recipe *recipe.Recipe, stagingDir string) []s
// executeCommand executes a single command
func (b *Builder) executeCommand(workDir, command string, env []string) error {
// Parse command
args := strings.Fields(command)
if len(args) == 0 {
if strings.TrimSpace(command) == "" {
return nil
}
cmd := exec.Command(args[0], args[1:]...)
cmd := exec.Command("sh", "-c", command)
cmd.Dir = workDir
cmd.Env = env
cmd.Stdout = os.Stdout
@ -182,12 +212,11 @@ func (b *Builder) executeCommand(workDir, command string, env []string) error {
// executeCommandWithOutput executes a command and returns output
func (b *Builder) executeCommandWithOutput(workDir, command string, env []string) (string, error) {
args := strings.Fields(command)
if len(args) == 0 {
if strings.TrimSpace(command) == "" {
return "", nil
}
cmd := exec.Command(args[0], args[1:]...)
cmd := exec.Command("sh", "-c", command)
cmd.Dir = workDir
cmd.Env = env
@ -280,7 +309,7 @@ func (b *Builder) SetWorkDir(workDir string) {
}
// substituteVariables substitutes variables in command strings
func (b *Builder) substituteVariables(cmdStr string, recipe *recipe.Recipe, sourceDir, stagingDir string) string {
func (b *Builder) substituteVariables(cmdStr, sourceDir, stagingDir string) string {
// Get number of CPU cores
jobs := runtime.NumCPU()
@ -288,12 +317,15 @@ func (b *Builder) substituteVariables(cmdStr string, recipe *recipe.Recipe, sour
cmdStr = strings.ReplaceAll(cmdStr, "${jobs}", fmt.Sprintf("%d", jobs))
cmdStr = strings.ReplaceAll(cmdStr, "${pkgdir}", stagingDir)
cmdStr = strings.ReplaceAll(cmdStr, "${srcdir}", sourceDir)
cmdStr = strings.ReplaceAll(cmdStr, "{{jobs}}", fmt.Sprintf("%d", jobs))
cmdStr = strings.ReplaceAll(cmdStr, "{{pkgdir}}", stagingDir)
cmdStr = strings.ReplaceAll(cmdStr, "{{srcdir}}", sourceDir)
return cmdStr
}
// packageFiles runs the package commands
func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir, packageDir string) error {
func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir string) error {
// Find the source directory (usually the first subdirectory)
srcPath, err := b.findSourceDirectory(sourceDir)
if err != nil {
@ -301,12 +333,12 @@ func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir, pac
}
// Set up environment
env := b.buildEnvironment(recipe, stagingDir)
env := b.buildEnvironment(stagingDir)
// Execute package commands
for i, cmd := range recipe.Package.Commands {
for i, cmd := range recipe.Install {
// Substitute variables in command
cmd = b.substituteVariables(cmd, recipe, sourceDir, stagingDir)
cmd = b.substituteVariables(cmd, sourceDir, stagingDir)
if err := b.executeCommand(srcPath, cmd, env); err != nil {
return fmt.Errorf("package command %d failed: %w", i+1, err)

View file

@ -1,14 +1,19 @@
package fetcher
import (
"bufio"
"crypto/sha256"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/mholt/archiver/v3"
"golang.org/x/sync/errgroup"
)
// Fetcher handles downloading and extracting sources
@ -23,15 +28,13 @@ func NewFetcher(cacheDir string) *Fetcher {
}
}
// Download downloads a file from URL and verifies checksum
// Download downloads a file from URL and verifies checksum when provided.
func (f *Fetcher) Download(url, expectedHash string) (string, error) {
// Validate input parameters
if url == "" {
return "", fmt.Errorf("URL cannot be empty")
}
if expectedHash == "" {
return "", fmt.Errorf("expected hash cannot be empty")
}
expectedHash = strings.ToLower(strings.TrimSpace(expectedHash))
filename := filepath.Base(url)
if filename == "" {
@ -40,10 +43,16 @@ func (f *Fetcher) Download(url, expectedHash string) (string, error) {
cachePath := filepath.Join(f.cacheDir, filename)
// Check if already downloaded and valid
// Check if already downloaded and valid.
if expectedHash != "" {
if f.isValidCache(cachePath, expectedHash) {
return cachePath, nil
}
} else {
if _, err := os.Stat(cachePath); err == nil {
return cachePath, nil
}
}
// Create cache directory if it doesn't exist
if err := os.MkdirAll(f.cacheDir, 0755); err != nil {
@ -66,22 +75,29 @@ func (f *Fetcher) Download(url, expectedHash string) (string, error) {
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
// Download to temp file
hasher := sha256.New()
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
if _, err := io.Copy(io.MultiWriter(tmpFile, hasher), resp.Body); err != nil {
_ = tmpFile.Close()
return "", fmt.Errorf("failed to download file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return "", fmt.Errorf("failed to close temp file: %w", err)
}
// Verify checksum
if expectedHash != "" {
calculatedHash := fmt.Sprintf("%x", hasher.Sum(nil))
if calculatedHash != expectedHash {
_ = os.Remove(tmpFile.Name())
return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, calculatedHash)
}
}
// Move temp file to final location
if err := os.Rename(tmpFile.Name(), cachePath); err != nil {
_ = os.Remove(tmpFile.Name())
return "", fmt.Errorf("failed to move downloaded file: %w", err)
}
@ -128,36 +144,28 @@ func (f *Fetcher) ApplyPatches(sourceDir string, patchFiles []string) error {
// applyPatch applies a single patch file
func (f *Fetcher) applyPatch(sourceDir, patchFile string) error {
// This is a simplified patch application
// In a real implementation, you'd want to use the 'patch' command
// For now, we'll just copy the patch file to the source directory
patchDest := filepath.Join(sourceDir, filepath.Base(patchFile))
if err := copyFile(patchFile, patchDest); err != nil {
return fmt.Errorf("failed to copy patch file: %w", err)
}
patchFile = filepath.Clean(patchFile)
// Try common strip levels used by patch files.
for _, stripLevel := range []string{"1", "0"} {
cmd := exec.Command("patch", "-p"+stripLevel, "-i", patchFile)
cmd.Dir = sourceDir
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err == nil {
return nil
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
return err
return fmt.Errorf("failed to apply patch with -p1 and -p0: %s", patchFile)
}
// DownloadAndExtract downloads and extracts source
func (f *Fetcher) DownloadAndExtract(url, expectedHash, destDir string) error {
if isDebianDSCURL(url) {
return f.downloadAndExtractFromDebianDSC(url, expectedHash, destDir)
}
archivePath, err := f.Download(url, expectedHash)
if err != nil {
return err
@ -166,6 +174,139 @@ func (f *Fetcher) DownloadAndExtract(url, expectedHash, destDir string) error {
return f.Extract(archivePath, destDir)
}
func (f *Fetcher) downloadAndExtractFromDebianDSC(dscURL, dscHash, destDir string) error {
dscPath, err := f.Download(dscURL, dscHash)
if err != nil {
return fmt.Errorf("failed to download debian dsc: %w", err)
}
entries, err := parseDebianDSCSHA256Entries(dscPath)
if err != nil {
return err
}
origEntries := make([]debianDSCEntry, 0, len(entries))
for _, entry := range entries {
if isDebianOrigArchive(entry.Name) {
origEntries = append(origEntries, entry)
}
}
if len(origEntries) == 0 {
return fmt.Errorf("debian dsc does not contain upstream orig archive")
}
base, err := neturl.Parse(dscURL)
if err != nil {
return fmt.Errorf("invalid dsc URL %q: %w", dscURL, err)
}
for _, entry := range origEntries {
ref, err := neturl.Parse(entry.Name)
if err != nil {
return fmt.Errorf("invalid dsc entry filename %q: %w", entry.Name, err)
}
fileURL := base.ResolveReference(ref).String()
archivePath, err := f.Download(fileURL, entry.SHA256)
if err != nil {
return fmt.Errorf("failed to download upstream source %s: %w", entry.Name, err)
}
if err := f.Extract(archivePath, destDir); err != nil {
return fmt.Errorf("failed to extract upstream source %s: %w", entry.Name, err)
}
}
return nil
}
type debianDSCEntry struct {
Name string
SHA256 string
}
func parseDebianDSCSHA256Entries(path string) ([]debianDSCEntry, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open dsc file %s: %w", path, err)
}
defer file.Close()
var entries []debianDSCEntry
scanner := bufio.NewScanner(file)
inSHA256Section := false
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if !inSHA256Section {
if strings.HasPrefix(trimmed, "Checksums-Sha256:") {
inSHA256Section = true
}
continue
}
if !isIndentedLine(line) {
break
}
fields := strings.Fields(trimmed)
if len(fields) < 3 {
return nil, fmt.Errorf("invalid Checksums-Sha256 entry in dsc: %q", line)
}
entries = append(entries, debianDSCEntry{
SHA256: strings.ToLower(fields[0]),
Name: fields[2],
})
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read dsc file %s: %w", path, err)
}
if len(entries) == 0 {
return nil, fmt.Errorf("debian dsc missing Checksums-Sha256 entries")
}
return entries, nil
}
func isIndentedLine(line string) bool {
if line == "" {
return false
}
return line[0] == ' ' || line[0] == '\t'
}
func isDebianOrigArchive(name string) bool {
idx := strings.Index(name, ".orig")
if idx < 0 {
return false
}
rest := name[idx+len(".orig"):]
if strings.HasPrefix(rest, ".tar.") {
return true
}
if strings.HasPrefix(rest, "-") && strings.Contains(rest, ".tar.") {
return true
}
return false
}
func isDebianDSCURL(raw string) bool {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" {
return false
}
if idx := strings.IndexAny(raw, "?#"); idx >= 0 {
raw = raw[:idx]
}
return strings.HasSuffix(raw, ".dsc")
}
// DownloadMultiple downloads multiple files concurrently
func (f *Fetcher) DownloadMultiple(urls []string, hashes []string) ([]string, error) {
if len(urls) != len(hashes) {
@ -173,12 +314,21 @@ func (f *Fetcher) DownloadMultiple(urls []string, hashes []string) ([]string, er
}
paths := make([]string, len(urls))
var g errgroup.Group
for i, url := range urls {
i, url := i, url
g.Go(func() error {
path, err := f.Download(url, hashes[i])
if err != nil {
return nil, err
return err
}
paths[i] = path
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return paths, nil

147
pkg/fetcher/fetcher_test.go Normal file
View file

@ -0,0 +1,147 @@
package fetcher
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
)
func TestDownloadAndExtractDebianDSCUsesOnlyOrig(t *testing.T) {
origArchive := buildTarGz(t, map[string]string{
"demo-1.0/README": "hello upstream\n",
})
debianPatch := []byte("pretend debian patch content")
origHash := hashHex(origArchive)
debianHash := hashHex(debianPatch)
dsc := fmt.Sprintf(`Format: 3.0 (quilt)
Source: demo
Checksums-Sha256:
%s %d demo_1.0.orig.tar.gz
%s %d demo_1.0-1.debian.tar.xz
`, origHash, len(origArchive), debianHash, len(debianPatch))
hits := map[string]int{}
withHTTPTransport(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
hits[req.URL.Path]++
switch req.URL.Path {
case "/pool/main/d/demo/demo_1.0-1.dsc":
return httpResponse(req, http.StatusOK, []byte(dsc)), nil
case "/pool/main/d/demo/demo_1.0.orig.tar.gz":
return httpResponse(req, http.StatusOK, origArchive), nil
case "/pool/main/d/demo/demo_1.0-1.debian.tar.xz":
return httpResponse(req, http.StatusOK, debianPatch), nil
default:
return httpResponse(req, http.StatusNotFound, []byte("not found")), nil
}
}))
workDir := t.TempDir()
f := NewFetcher(filepath.Join(workDir, "cache"))
destDir := filepath.Join(workDir, "src")
err := f.DownloadAndExtract("https://deb.example/pool/main/d/demo/demo_1.0-1.dsc", "", destDir)
if err != nil {
t.Fatalf("DownloadAndExtract() error = %v", err)
}
readmePath := filepath.Join(destDir, "demo-1.0", "README")
content, err := os.ReadFile(readmePath)
if err != nil {
t.Fatalf("failed to read extracted file %s: %v", readmePath, err)
}
if string(content) != "hello upstream\n" {
t.Fatalf("unexpected extracted file content: %q", string(content))
}
debianHits := hits["/pool/main/d/demo/demo_1.0-1.debian.tar.xz"]
origHits := hits["/pool/main/d/demo/demo_1.0.orig.tar.gz"]
dscHits := hits["/pool/main/d/demo/demo_1.0-1.dsc"]
if dscHits == 0 || origHits == 0 {
t.Fatalf("expected dsc and orig to be downloaded, got dsc=%d orig=%d", dscHits, origHits)
}
if debianHits != 0 {
t.Fatalf("expected debian patch archive to be ignored, got %d requests", debianHits)
}
}
func TestDownloadAndExtractDebianDSCChecksumMismatch(t *testing.T) {
withHTTPTransport(t, roundTripFunc(func(req *http.Request) (*http.Response, error) {
return httpResponse(req, http.StatusOK, []byte("Format: 3.0 (quilt)\nSource: demo\n")), nil
}))
f := NewFetcher(filepath.Join(t.TempDir(), "cache"))
err := f.DownloadAndExtract("https://deb.example/demo_1.0-1.dsc", "deadbeef", t.TempDir())
if err == nil {
t.Fatalf("expected checksum mismatch error")
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func withHTTPTransport(t *testing.T, transport http.RoundTripper) {
t.Helper()
orig := http.DefaultTransport
http.DefaultTransport = transport
t.Cleanup(func() {
http.DefaultTransport = orig
})
}
func httpResponse(req *http.Request, code int, body []byte) *http.Response {
return &http.Response{
StatusCode: code,
Body: io.NopCloser(bytes.NewReader(body)),
Header: make(http.Header),
Request: req,
}
}
func buildTarGz(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for name, content := range files {
hdr := &tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(content)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("failed to write tar header: %v", err)
}
if _, err := tw.Write([]byte(content)); err != nil {
t.Fatalf("failed to write tar body: %v", err)
}
}
if err := tw.Close(); err != nil {
t.Fatalf("failed to close tar writer: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("failed to close gzip writer: %v", err)
}
return buf.Bytes()
}
func hashHex(data []byte) string {
sum := sha256.Sum256(data)
return fmt.Sprintf("%x", sum)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,261 @@
package installer
import (
"os"
"path/filepath"
"strings"
"testing"
"zsvo/pkg/packager"
"zsvo/pkg/recipe"
"zsvo/pkg/types"
)
func buildTestPackage(t *testing.T, workDir string, r *recipe.Recipe, files map[string][]byte) string {
t.Helper()
stagingDir := r.GetStagingDir(workDir)
for relPath, content := range files {
path := filepath.Join(stagingDir, relPath)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", path, err)
}
if err := os.WriteFile(path, content, 0o755); err != nil {
t.Fatalf("WriteFile(%s) error = %v", path, err)
}
}
p := packager.NewPackager(workDir)
if err := p.Package(r); err != nil {
t.Fatalf("Package(%s) error = %v", r.GetPackageName(), err)
}
return p.GetPackageFile(r)
}
func TestInstallPackageCopiesFilesAndSymlinks(t *testing.T) {
t.Parallel()
workDir := t.TempDir()
rootDir := t.TempDir()
r := &recipe.Recipe{Name: "demo", Version: "1.0.0", Build: []string{"true"}, Install: []string{"true"}}
stagingDir := r.GetStagingDir(workDir)
if err := os.MkdirAll(filepath.Join(stagingDir, "usr", "bin"), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(filepath.Join(stagingDir, "usr", "bin", "demo"), []byte("demo\n"), 0o755); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := os.Symlink("demo", filepath.Join(stagingDir, "usr", "bin", "demo-link")); err != nil {
t.Fatalf("Symlink() error = %v", err)
}
p := packager.NewPackager(workDir)
if err := p.Package(r); err != nil {
t.Fatalf("Package() error = %v", err)
}
ins := NewInstaller(rootDir)
if err := ins.Install(p.GetPackageFile(r)); err != nil {
t.Fatalf("Install() error = %v", err)
}
if _, err := os.Stat(filepath.Join(rootDir, "usr", "bin", "demo")); err != nil {
t.Fatalf("installed file missing: %v", err)
}
if target, err := os.Readlink(filepath.Join(rootDir, "usr", "bin", "demo-link")); err != nil {
t.Fatalf("Readlink() error = %v", err)
} else if target != "demo" {
t.Fatalf("unexpected symlink target: %s", target)
}
}
func TestInstallManyOrdersByDependencies(t *testing.T) {
t.Parallel()
workDir := t.TempDir()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
libPkg := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "libfoo",
Version: "1.0.0",
Build: []string{"true"},
Install: []string{"true"},
}, map[string][]byte{"usr/lib/libfoo.so": []byte("lib\n")})
appPkg := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "app",
Version: "1.0.0",
Build: []string{"true"},
Install: []string{"true"},
Deps: []string{"libfoo"},
}, map[string][]byte{"usr/bin/app": []byte("app\n")})
// app передан первым, installer должен поставить libfoo раньше.
if err := ins.InstallMany([]string{appPkg, libPkg}); err != nil {
t.Fatalf("InstallMany() error = %v", err)
}
if !ins.IsInstalled("libfoo") || !ins.IsInstalled("app") {
t.Fatalf("expected libfoo and app to be installed")
}
}
func TestInstallManyRollsBackOnFailure(t *testing.T) {
t.Parallel()
workDir := t.TempDir()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
libPkg := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "libfoo",
Version: "1.0.0",
Build: []string{"true"},
Install: []string{"true"},
}, map[string][]byte{"usr/lib/libfoo.so": []byte("lib\n")})
appPkg := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "app",
Version: "1.0.0",
Build: []string{"true"},
Install: []string{"true"},
Deps: []string{"libfoo"},
}, map[string][]byte{"usr/bin/app": []byte("app\n")})
brokenPkg := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "broken",
Version: "1.0.0",
Build: []string{"true"},
Install: []string{"true"},
Deps: []string{"missing"},
}, map[string][]byte{"usr/bin/broken": []byte("broken\n")})
err := ins.InstallMany([]string{appPkg, libPkg, brokenPkg})
if err == nil {
t.Fatalf("expected InstallMany() to fail")
}
if ins.IsInstalled("libfoo") || ins.IsInstalled("app") || ins.IsInstalled("broken") {
t.Fatalf("expected transaction rollback")
}
}
func TestInstallRespectsRootFlag(t *testing.T) {
t.Parallel()
workDir := t.TempDir()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
pkgFile := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "rooted",
Version: "1.0.0",
Build: []string{"true"},
Install: []string{"true"},
}, map[string][]byte{"usr/bin/rooted": []byte("ok\n")})
if err := ins.Install(pkgFile); err != nil {
t.Fatalf("Install() error = %v", err)
}
if _, err := os.Stat(filepath.Join(rootDir, "usr", "bin", "rooted")); err != nil {
t.Fatalf("expected file in custom root: %v", err)
}
}
func TestRemoveManyWithoutCascadeFails(t *testing.T) {
t.Parallel()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
lib := &types.PkgInfo{Name: "libfoo", Version: "1.0.0", Files: []string{"usr/lib/libfoo.so"}}
app := &types.PkgInfo{Name: "app", Version: "1.0.0", Dependencies: []string{"libfoo"}, Files: []string{"usr/bin/app"}}
if err := ins.registerPackage(lib); err != nil {
t.Fatalf("registerPackage(lib) error = %v", err)
}
if err := ins.registerPackage(app); err != nil {
t.Fatalf("registerPackage(app) error = %v", err)
}
_, err := ins.RemoveMany([]string{"libfoo"}, RemoveOptions{})
if err == nil {
t.Fatalf("expected RemoveMany() to fail")
}
if !strings.Contains(err.Error(), "required by") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRemoveManyCascade(t *testing.T) {
t.Parallel()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
lib := &types.PkgInfo{Name: "libfoo", Version: "1.0.0", Files: []string{"usr/lib/libfoo.so"}}
app := &types.PkgInfo{Name: "app", Version: "1.0.0", Dependencies: []string{"libfoo"}, Files: []string{"usr/bin/app"}}
if err := ins.registerPackage(lib); err != nil {
t.Fatalf("registerPackage(lib) error = %v", err)
}
if err := ins.registerPackage(app); err != nil {
t.Fatalf("registerPackage(app) error = %v", err)
}
removed, err := ins.RemoveMany([]string{"libfoo"}, RemoveOptions{Cascade: true})
if err != nil {
t.Fatalf("RemoveMany(cascade) error = %v", err)
}
if len(removed) != 2 {
t.Fatalf("expected two removed packages, got %d", len(removed))
}
if ins.IsInstalled("libfoo") || ins.IsInstalled("app") {
t.Fatalf("expected packages to be removed")
}
}
func TestListOrphans(t *testing.T) {
t.Parallel()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
lib := &types.PkgInfo{Name: "libfoo", Version: "1.0.0"}
app := &types.PkgInfo{Name: "app", Version: "1.0.0", Dependencies: []string{"libfoo"}}
tool := &types.PkgInfo{Name: "tool", Version: "1.0.0"}
if err := ins.registerPackage(lib); err != nil {
t.Fatalf("registerPackage(lib) error = %v", err)
}
if err := ins.registerPackage(app); err != nil {
t.Fatalf("registerPackage(app) error = %v", err)
}
if err := ins.registerPackage(tool); err != nil {
t.Fatalf("registerPackage(tool) error = %v", err)
}
orphans, err := ins.ListOrphans()
if err != nil {
t.Fatalf("ListOrphans() error = %v", err)
}
set := make(map[string]struct{}, len(orphans))
for _, name := range orphans {
set[name] = struct{}{}
}
if _, ok := set["app"]; !ok {
t.Fatalf("expected app to be orphan")
}
if _, ok := set["tool"]; !ok {
t.Fatalf("expected tool to be orphan")
}
if _, ok := set["libfoo"]; ok {
t.Fatalf("did not expect libfoo to be orphan")
}
}

View file

@ -1,13 +1,14 @@
package packager
import (
"bufio"
"archive/tar"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/klauspost/compress/zstd"
"github.com/mholt/archiver/v3"
"zsvo/pkg/recipe"
"zsvo/pkg/types"
@ -74,19 +75,20 @@ func (p *Packager) Package(recipe *recipe.Recipe) error {
Name: recipe.Name,
Version: recipe.Version,
Description: recipe.Description,
Dependencies: recipe.Dependencies,
Dependencies: recipe.Deps,
Files: files,
InstallDate: time.Now().Format(time.RFC3339),
}
// Create package info file
pkgInfoFile := filepath.Join(packageDir, ".pkginfo")
pkgInfoFile := filepath.Join(stagingDir, types.PackageMetadataFile)
if err := p.writePkgInfo(pkgInfoFile, pkgInfo); err != nil {
return fmt.Errorf("failed to write package info to %s: %w", pkgInfoFile, err)
}
defer os.Remove(pkgInfoFile)
// Create package archive
if err := p.createArchive(stagingDir, packageFile, pkgInfoFile); err != nil {
if err := p.createArchive(stagingDir, packageFile); err != nil {
return fmt.Errorf("failed to create package archive %s: %w", packageFile, err)
}
@ -123,20 +125,76 @@ func (p *Packager) writePkgInfo(path string, pkgInfo *types.PkgInfo) error {
}
defer file.Close()
// Write package info in simple format
content := fmt.Sprintf(
"name = %q\nversion = %q\ndescription = %q\ndependencies = %v\nfiles = %v\ninstall_date = %q\n",
pkgInfo.Name, pkgInfo.Version, pkgInfo.Description, pkgInfo.Dependencies, pkgInfo.Files, pkgInfo.InstallDate,
)
_, err = file.WriteString(content)
return err
return types.WritePkgInfo(file, pkgInfo)
}
// createArchive creates a compressed archive
func (p *Packager) createArchive(sourceDir, archivePath, pkgInfoFile string) error {
// Create archive using archiver.Archive
paths := []string{sourceDir, pkgInfoFile}
return archiver.Archive(paths, archivePath)
func (p *Packager) createArchive(sourceDir, archivePath string) error {
outFile, err := os.Create(archivePath)
if err != nil {
return err
}
defer outFile.Close()
zstdWriter, err := zstd.NewWriter(outFile)
if err != nil {
return err
}
defer zstdWriter.Close()
tarWriter := tar.NewWriter(zstdWriter)
defer tarWriter.Close()
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == sourceDir {
return nil
}
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
relPath = filepath.ToSlash(relPath)
var linkTarget string
if info.Mode()&os.ModeSymlink != 0 {
linkTarget, err = os.Readlink(path)
if err != nil {
return err
}
}
header, err := tar.FileInfoHeader(info, linkTarget)
if err != nil {
return err
}
header.Name = relPath
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if !info.Mode().IsRegular() {
return nil
}
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 nil
})
}
// Extract extracts a package archive
@ -164,9 +222,9 @@ func (p *Packager) ReadPkgInfo(archivePath string) (*types.PkgInfo, error) {
}
// Read package info
pkgInfoFile := filepath.Join(tmpDir, ".pkginfo")
pkgInfoFile := filepath.Join(tmpDir, types.PackageMetadataFile)
// Check if .pkginfo file exists
// Check if metadata file exists.
if _, err := os.Stat(pkgInfoFile); os.IsNotExist(err) {
return nil, fmt.Errorf("package info file not found in archive")
}
@ -182,67 +240,7 @@ func (p *Packager) readPkgInfoFromFile(path string) (*types.PkgInfo, error) {
}
defer file.Close()
var pkgInfo types.PkgInfo
// Simple parser for our package info format
// In a real implementation, you'd want to use a proper TOML parser
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Remove quotes from value
value = strings.Trim(value, "\"")
switch key {
case "name":
pkgInfo.Name = value
case "version":
pkgInfo.Version = value
case "description":
pkgInfo.Description = value
case "dependencies":
// Parse dependencies array
value = strings.Trim(value, "[]")
if value != "" {
pkgInfo.Dependencies = strings.Split(value, ",")
for i, dep := range pkgInfo.Dependencies {
pkgInfo.Dependencies[i] = strings.TrimSpace(strings.Trim(dep, "\""))
}
}
case "files":
// Parse files array
value = strings.Trim(value, "[]")
if value != "" {
pkgInfo.Files = strings.Split(value, ",")
for i, file := range pkgInfo.Files {
pkgInfo.Files[i] = strings.TrimSpace(strings.Trim(file, "\""))
}
}
case "install_date":
pkgInfo.InstallDate = value
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
// Validate that we actually parsed some data
if pkgInfo.Name == "" {
return nil, fmt.Errorf("invalid package info: missing name")
}
return &pkgInfo, nil
return types.ReadPkgInfo(file)
}
// VerifyPackage verifies package integrity
@ -294,16 +292,3 @@ func (p *Packager) Clean(recipe *recipe.Recipe) error {
packageDir := p.GetPackageDir(recipe)
return os.RemoveAll(packageDir)
}
// pkgInfoFileInfo implements os.FileInfo for package info
type pkgInfoFileInfo struct {
name string
size int64
}
func (f *pkgInfoFileInfo) Name() string { return f.name }
func (f *pkgInfoFileInfo) Size() int64 { return f.size }
func (f *pkgInfoFileInfo) Mode() os.FileMode { return 0644 }
func (f *pkgInfoFileInfo) ModTime() time.Time { return time.Now() }
func (f *pkgInfoFileInfo) IsDir() bool { return false }
func (f *pkgInfoFileInfo) Sys() interface{} { return nil }

View file

@ -0,0 +1,67 @@
package packager
import (
"os"
"path/filepath"
"testing"
"zsvo/pkg/recipe"
)
func TestPackageAndReadPkgInfo(t *testing.T) {
t.Parallel()
workDir := t.TempDir()
stagingDir := filepath.Join(workDir, "staging", "demo-1.0.0")
if err := os.MkdirAll(filepath.Join(stagingDir, "usr", "bin"), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(filepath.Join(stagingDir, "usr", "bin", "demo"), []byte("demo\n"), 0o755); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
r := &recipe.Recipe{
Name: "demo",
Version: "1.0.0",
Deps: []string{"glibc", "zlib"},
}
p := NewPackager(workDir)
if err := p.Package(r); err != nil {
t.Fatalf("Package() error = %v", err)
}
archive := p.GetPackageFile(r)
info, err := p.ReadPkgInfo(archive)
if err != nil {
t.Fatalf("ReadPkgInfo() error = %v", err)
}
if len(info.Dependencies) != 2 || info.Dependencies[0] != "glibc" || info.Dependencies[1] != "zlib" {
t.Fatalf("dependencies mismatch: %#v", info.Dependencies)
}
if len(info.Files) != 1 || info.Files[0] != "usr/bin/demo" {
t.Fatalf("files mismatch: %#v", info.Files)
}
contents, err := p.ListPackageContents(archive)
if err != nil {
t.Fatalf("ListPackageContents() error = %v", err)
}
hasPkgInfo := false
hasBinary := false
for _, entry := range contents {
if entry == ".zsvo.yml" {
hasPkgInfo = true
}
if entry == "usr/bin/demo" {
hasBinary = true
}
}
if !hasPkgInfo || !hasBinary {
t.Fatalf("archive contents missing expected files: %#v", contents)
}
}

View file

@ -1,110 +1,279 @@
package recipe
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
"strings"
)
// Recipe represents a package recipe
// Recipe represents a package recipe.
type Recipe struct {
Name string `toml:"name"`
Version string `toml:"version"`
Description string `toml:"description,omitempty"`
Source Source `toml:"source"`
Build Build `toml:"build"`
Package Package `toml:"package"`
Dependencies []string `toml:"dependencies,omitempty"`
Options map[string]bool `toml:"options,omitempty"`
Name string
Version string
Description string
Source Source
Build []string
Install []string
Deps []string
Dir string
}
// Source represents source information
// Source represents source information.
type Source struct {
URL string `toml:"url"`
Sha256 string `toml:"sha256"`
Patches []string `toml:"patches,omitempty"`
URL string
Sha256 string
DebianDSC string
Patches []string
}
// Build represents build configuration
type Build struct {
Commands []string `toml:"commands"`
Env []string `toml:"env,omitempty"`
}
// Package represents package configuration
type Package struct {
Commands []string `toml:"commands"`
}
// ParseRecipe parses a recipe from a TOML file
// ParseRecipe parses a recipe from a YAML file.
func ParseRecipe(path string) (*Recipe, error) {
// Validate input path
if path == "" {
return nil, fmt.Errorf("recipe path cannot be empty")
}
// Clean and normalize path
path = filepath.Clean(path)
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to open recipe file %s: %w", path, err)
}
defer file.Close()
return ParseRecipeFromReader(file)
rcp, err := ParseRecipeFromReader(file)
if err != nil {
return nil, err
}
rcp.Dir = filepath.Dir(path)
return rcp, nil
}
// ParseRecipeFromReader parses a recipe from an io.Reader
// ParseRecipeFromReader parses a recipe from an io.Reader.
func ParseRecipeFromReader(r io.Reader) (*Recipe, error) {
var recipe Recipe
if _, err := toml.NewDecoder(r).Decode(&recipe); err != nil {
return nil, fmt.Errorf("failed to parse recipe: %w", err)
rcp := &Recipe{}
scanner := bufio.NewScanner(r)
section := ""
sourceSubsection := ""
lineNo := 0
for scanner.Scan() {
lineNo++
raw := strings.TrimRight(scanner.Text(), "\r")
trimmed := strings.TrimSpace(raw)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if recipe.Name == "" {
indent := countLeadingSpaces(raw)
if strings.HasPrefix(trimmed, "- ") {
item := decodeYAMLScalar(strings.TrimSpace(trimmed[2:]))
if item == "" {
return nil, fmt.Errorf("invalid empty list item at line %d", lineNo)
}
switch section {
case "build":
rcp.Build = append(rcp.Build, item)
case "install":
rcp.Install = append(rcp.Install, item)
case "deps":
rcp.Deps = append(rcp.Deps, item)
case "source":
if sourceSubsection != "patches" {
return nil, fmt.Errorf("unexpected source list item 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)
}
continue
}
if strings.HasSuffix(trimmed, ":") {
key := strings.TrimSpace(strings.TrimSuffix(trimmed, ":"))
if key == "" {
return nil, fmt.Errorf("invalid key at line %d", lineNo)
}
if indent == 0 {
section = key
sourceSubsection = ""
continue
}
if section == "source" && indent >= 2 {
sourceSubsection = key
continue
}
return nil, fmt.Errorf("unsupported nested section at line %d", lineNo)
}
key, val, ok := splitYAMLKeyValue(trimmed)
if !ok {
return nil, fmt.Errorf("invalid recipe line %d: %s", lineNo, trimmed)
}
value := decodeYAMLScalar(val)
if indent == 0 {
section = ""
sourceSubsection = ""
switch key {
case "name":
rcp.Name = value
case "version":
rcp.Version = value
case "description":
rcp.Description = value
case "source", "build", "install", "deps":
// Allowed as section headers only.
return nil, fmt.Errorf("section %q must be a block (line %d)", key, lineNo)
default:
return nil, fmt.Errorf("unknown top-level key %q at line %d", key, lineNo)
}
continue
}
if section == "source" {
sourceSubsection = ""
switch key {
case "url":
rcp.Source.URL = value
case "sha256":
rcp.Source.Sha256 = value
case "debian_dsc", "dsc_url":
rcp.Source.DebianDSC = value
case "patches":
return nil, fmt.Errorf("source.patches must be a list (line %d)", lineNo)
default:
return nil, fmt.Errorf("unknown source key %q at line %d", key, lineNo)
}
continue
}
return nil, fmt.Errorf("unexpected nested key at line %d", lineNo)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read recipe: %w", err)
}
if rcp.Name == "" {
return nil, fmt.Errorf("recipe must have a name")
}
if recipe.Version == "" {
if rcp.Version == "" {
return nil, fmt.Errorf("recipe must have a version")
}
if recipe.Source.URL == "" {
return nil, fmt.Errorf("recipe must have a source URL")
if rcp.Source.URL != "" && rcp.Source.DebianDSC != "" {
return nil, fmt.Errorf("source.url and source.debian_dsc are mutually exclusive")
}
if recipe.Source.Sha256 == "" {
sourceURL := strings.TrimSpace(rcp.Source.URL)
if rcp.Source.DebianDSC != "" {
if !isDebianDSCURL(rcp.Source.DebianDSC) {
return nil, fmt.Errorf("source.debian_dsc must point to a .dsc file")
}
sourceURL = strings.TrimSpace(rcp.Source.DebianDSC)
}
if sourceURL == "" {
return nil, fmt.Errorf("recipe must have source.url or source.debian_dsc")
}
// SHA256 is mandatory for direct archives, optional for Debian .dsc flow.
if rcp.Source.Sha256 == "" && !isDebianDSCURL(sourceURL) {
return nil, fmt.Errorf("recipe must have a source SHA256 checksum")
}
if len(recipe.Build.Commands) == 0 {
if len(rcp.Build) == 0 {
return nil, fmt.Errorf("recipe must have build commands")
}
return &recipe, nil
if len(rcp.Install) == 0 {
return nil, fmt.Errorf("recipe must have install commands")
}
// GetPackageName returns the package name in format name-version
for _, dep := range rcp.Deps {
dep = strings.TrimSpace(dep)
if dep == "" {
return nil, fmt.Errorf("recipe deps cannot contain empty values")
}
if strings.ContainsAny(dep, "|<>= \t\n\r") {
return nil, fmt.Errorf("unsupported dependency format %q: use plain package names", dep)
}
}
return rcp, nil
}
func splitYAMLKeyValue(line string) (string, string, bool) {
idx := strings.Index(line, ":")
if idx <= 0 {
return "", "", false
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
if key == "" {
return "", "", false
}
return key, val, true
}
func decodeYAMLScalar(v string) string {
v = strings.TrimSpace(v)
if len(v) >= 2 {
if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') {
return v[1 : len(v)-1]
}
}
return v
}
func countLeadingSpaces(s string) int {
count := 0
for _, ch := range s {
if ch != ' ' {
break
}
count++
}
return count
}
func isDebianDSCURL(raw string) bool {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" {
return false
}
if idx := strings.IndexAny(raw, "?#"); idx >= 0 {
raw = raw[:idx]
}
return strings.HasSuffix(raw, ".dsc")
}
// GetPackageName returns the package name in format name-version.
func (r *Recipe) GetPackageName() string {
return fmt.Sprintf("%s-%s", r.Name, r.Version)
}
// GetPackageFileName returns the package file name
// GetPackageFileName returns the package file name.
func (r *Recipe) GetPackageFileName() string {
return fmt.Sprintf("%s.pkg.tar.zst", r.GetPackageName())
}
// GetPackageDir returns the package directory path
// GetPackageDir returns the package directory path.
func (r *Recipe) GetPackageDir(baseDir string) string {
return filepath.Join(baseDir, "packages", r.GetPackageName())
}
// GetSourceDir returns the source directory path
// GetSourceDir returns the source directory path.
func (r *Recipe) GetSourceDir(baseDir string) string {
return filepath.Join(baseDir, "sources", r.GetPackageName())
}
// GetStagingDir returns the staging directory path
// GetStagingDir returns the staging directory path.
func (r *Recipe) GetStagingDir(baseDir string) string {
return filepath.Join(baseDir, "staging", r.GetPackageName())
}

118
pkg/recipe/recipe_test.go Normal file
View file

@ -0,0 +1,118 @@
package recipe
import (
"strings"
"testing"
)
func TestParseRecipeFromReaderYAML(t *testing.T) {
t.Parallel()
input := `
name: bash
version: "5.2"
description: GNU shell
source:
url: https://ftp.gnu.org/gnu/bash/bash-5.2.tar.gz
sha256: deadbeef
build:
- ./configure --prefix=/usr
- make -j$(nproc)
install:
- make DESTDIR={{pkgdir}} install
deps:
- glibc
- readline
`
r, err := ParseRecipeFromReader(strings.NewReader(input))
if err != nil {
t.Fatalf("ParseRecipeFromReader() error = %v", err)
}
if r.Name != "bash" || r.Version != "5.2" {
t.Fatalf("unexpected recipe identity: %s %s", r.Name, r.Version)
}
if len(r.Build) != 2 || len(r.Install) != 1 || len(r.Deps) != 2 {
t.Fatalf("unexpected parsed sections: build=%d install=%d deps=%d", len(r.Build), len(r.Install), len(r.Deps))
}
}
func TestParseRecipeRejectsComplexDependencySyntax(t *testing.T) {
t.Parallel()
input := `
name: app
version: "1.0.0"
source:
url: https://example.org/app.tar.gz
sha256: deadbeef
build:
- make
install:
- make DESTDIR={{pkgdir}} install
deps:
- glibc>=2.39
`
_, err := ParseRecipeFromReader(strings.NewReader(input))
if err == nil {
t.Fatalf("expected parse error for unsupported dependency syntax")
}
}
func TestParseRecipeFromReaderDebianDSC(t *testing.T) {
t.Parallel()
input := `
name: coreutils
version: "9.5"
source:
debian_dsc: https://deb.debian.org/debian/pool/main/c/coreutils/coreutils_9.5-1.dsc
build:
- ./configure --prefix=/usr
- make -j$(nproc)
install:
- make DESTDIR={{pkgdir}} install
deps:
- glibc
`
r, err := ParseRecipeFromReader(strings.NewReader(input))
if err != nil {
t.Fatalf("ParseRecipeFromReader() error = %v", err)
}
if r.Source.DebianDSC == "" {
t.Fatalf("expected source.debian_dsc to be parsed")
}
if r.Source.Sha256 != "" {
t.Fatalf("expected empty source.sha256 for debian dsc flow")
}
}
func TestParseRecipeRejectsURLAndDebianDSCTogether(t *testing.T) {
t.Parallel()
input := `
name: demo
version: "1.0"
source:
url: https://example.org/demo.tar.gz
sha256: deadbeef
debian_dsc: https://deb.debian.org/debian/pool/main/d/demo/demo_1.0-1.dsc
build:
- make
install:
- make DESTDIR={{pkgdir}} install
`
_, err := ParseRecipeFromReader(strings.NewReader(input))
if err == nil {
t.Fatalf("expected parse error for mutually exclusive source fields")
}
}

View file

@ -1,11 +1,13 @@
package types
const PackageMetadataFile = ".zsvo.yml"
// PkgInfo represents package metadata
type PkgInfo struct {
Name string `toml:"name"`
Version string `toml:"version"`
Description string `toml:"description,omitempty"`
Dependencies []string `toml:"dependencies,omitempty"`
Files []string `toml:"files"`
InstallDate string `toml:"install_date"`
Name string `yaml:"name"`
Version string `yaml:"version"`
Description string `yaml:"description,omitempty"`
Dependencies []string `yaml:"deps,omitempty"`
Files []string `yaml:"files"`
InstallDate string `yaml:"install_date"`
}

191
pkg/types/pkginfo_codec.go Normal file
View file

@ -0,0 +1,191 @@
package types
import (
"bufio"
"fmt"
"io"
"strings"
)
// Validate ensures the package metadata is usable.
func (p *PkgInfo) Validate() error {
if p == nil {
return fmt.Errorf("package info cannot be nil")
}
if strings.TrimSpace(p.Name) == "" {
return fmt.Errorf("package info: missing name")
}
if strings.TrimSpace(p.Version) == "" {
return fmt.Errorf("package info: missing version")
}
for _, dep := range p.Dependencies {
if strings.TrimSpace(dep) == "" {
return fmt.Errorf("package info: empty dependency")
}
}
for _, file := range p.Files {
if strings.TrimSpace(file) == "" {
return fmt.Errorf("package info: empty file path")
}
}
return nil
}
// WritePkgInfo encodes package metadata as YAML.
func WritePkgInfo(w io.Writer, p *PkgInfo) error {
if err := p.Validate(); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "name: %s\n", yamlScalar(p.Name)); err != nil {
return fmt.Errorf("encode pkginfo: %w", err)
}
if _, err := fmt.Fprintf(w, "version: %s\n", yamlScalar(p.Version)); err != nil {
return fmt.Errorf("encode pkginfo: %w", err)
}
if strings.TrimSpace(p.Description) != "" {
if _, err := fmt.Fprintf(w, "description: %s\n", yamlScalar(p.Description)); err != nil {
return fmt.Errorf("encode pkginfo: %w", err)
}
}
if len(p.Dependencies) > 0 {
if _, err := io.WriteString(w, "deps:\n"); err != nil {
return fmt.Errorf("encode pkginfo: %w", err)
}
for _, dep := range p.Dependencies {
if _, err := fmt.Fprintf(w, " - %s\n", yamlScalar(dep)); err != nil {
return fmt.Errorf("encode pkginfo: %w", err)
}
}
}
if len(p.Files) > 0 {
if _, err := io.WriteString(w, "files:\n"); err != nil {
return fmt.Errorf("encode pkginfo: %w", err)
}
for _, file := range p.Files {
if _, err := fmt.Fprintf(w, " - %s\n", yamlScalar(file)); err != nil {
return fmt.Errorf("encode pkginfo: %w", err)
}
}
}
if strings.TrimSpace(p.InstallDate) != "" {
if _, err := fmt.Fprintf(w, "install_date: %s\n", yamlScalar(p.InstallDate)); err != nil {
return fmt.Errorf("encode pkginfo: %w", err)
}
}
return nil
}
// ReadPkgInfo decodes package metadata from YAML.
func ReadPkgInfo(r io.Reader) (*PkgInfo, error) {
var p PkgInfo
scanner := bufio.NewScanner(r)
section := ""
lineNo := 0
for scanner.Scan() {
lineNo++
raw := strings.TrimRight(scanner.Text(), "\r")
trimmed := strings.TrimSpace(raw)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if strings.HasPrefix(trimmed, "- ") {
item := yamlDecodeScalar(strings.TrimSpace(trimmed[2:]))
if item == "" {
return nil, fmt.Errorf("decode pkginfo: empty list item at line %d", lineNo)
}
switch section {
case "deps":
p.Dependencies = append(p.Dependencies, item)
case "files":
p.Files = append(p.Files, item)
default:
return nil, fmt.Errorf("decode pkginfo: list item outside section at line %d", lineNo)
}
continue
}
if strings.HasSuffix(trimmed, ":") {
key := strings.TrimSpace(strings.TrimSuffix(trimmed, ":"))
switch key {
case "deps", "files":
section = key
continue
default:
return nil, fmt.Errorf("decode pkginfo: unsupported block key %q at line %d", key, lineNo)
}
}
key, val, ok := splitPkgInfoKeyValue(trimmed)
if !ok {
return nil, fmt.Errorf("decode pkginfo: invalid line %d", lineNo)
}
section = ""
decoded := yamlDecodeScalar(val)
switch key {
case "name":
p.Name = decoded
case "version":
p.Version = decoded
case "description":
p.Description = decoded
case "install_date":
p.InstallDate = decoded
default:
return nil, fmt.Errorf("decode pkginfo: unknown key %q at line %d", key, lineNo)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("decode pkginfo: %w", err)
}
if err := p.Validate(); err != nil {
return nil, err
}
return &p, nil
}
func yamlScalar(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return `""`
}
v = strings.ReplaceAll(v, `\`, `\\`)
v = strings.ReplaceAll(v, `"`, `\"`)
return `"` + v + `"`
}
func yamlDecodeScalar(v string) string {
v = strings.TrimSpace(v)
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
v = v[1 : len(v)-1]
v = strings.ReplaceAll(v, `\"`, `"`)
v = strings.ReplaceAll(v, `\\`, `\`)
return v
}
if len(v) >= 2 && v[0] == '\'' && v[len(v)-1] == '\'' {
return v[1 : len(v)-1]
}
return v
}
func splitPkgInfoKeyValue(line string) (string, string, bool) {
idx := strings.Index(line, ":")
if idx <= 0 {
return "", "", false
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
if key == "" {
return "", "", false
}
return key, val, true
}

View file

@ -0,0 +1,34 @@
package types
import (
"bytes"
"reflect"
"testing"
)
func TestPkgInfoCodecRoundTrip(t *testing.T) {
t.Parallel()
original := &PkgInfo{
Name: "demo",
Version: "1.2.3",
Description: "demo package",
Dependencies: []string{"glibc", "zlib"},
Files: []string{"usr/bin/demo", "usr/share/doc/demo.txt"},
InstallDate: "2026-03-12T20:00:00Z",
}
var buf bytes.Buffer
if err := WritePkgInfo(&buf, original); err != nil {
t.Fatalf("WritePkgInfo() error = %v", err)
}
decoded, err := ReadPkgInfo(&buf)
if err != nil {
t.Fatalf("ReadPkgInfo() error = %v", err)
}
if !reflect.DeepEqual(original, decoded) {
t.Fatalf("decoded pkginfo mismatch:\nwant: %#v\ngot: %#v", original, decoded)
}
}

View file

@ -1,23 +0,0 @@
name = "zlib"
version = "1.3"
description = "Compression library implementing the deflate compression method found in gzip and PKZIP"
[source]
url = "https://zlib.net/fossils/zlib-1.3.tar.gz"
sha256 = "ff0ba4c292013dbc27530b3a81e1f9a813cd39de01ca5e0f8bf355702efa593e"
patches = []
[build]
commands = [
"./configure --prefix=/usr --shared",
"make -j${jobs}"
]
[package]
commands = [
"make DESTDIR=${pkgdir} install"
]
env = [
"CFLAGS=-O2 -fPIC"
]

17
recipes/zlib.yaml Normal file
View file

@ -0,0 +1,17 @@
name: zlib
version: "1.3"
description: Compression library implementing deflate compression
source:
url: https://zlib.net/fossils/zlib-1.3.tar.gz
sha256: ff0ba4c292013dbc27530b3a81e1f9a813cd39de01ca5e0f8bf355702efa593e
build:
- ./configure --prefix=/usr --shared
- make -j$(nproc)
install:
- make DESTDIR={{pkgdir}} install
deps:
- glibc

BIN
zsvo

Binary file not shown.