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

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 # Dependency directories
vendor/ vendor/
node_modules/ node_modules/
yay/
# Environment files # Environment files
.env .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:
``` `recipe -> download source -> extract -> build -> install into DESTDIR -> create package -> install package`
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
```
## Modules ## Commands
### 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
```bash ```bash
# Build from recipe # Build package from recipe
pkg build recipes/zlib.toml zsvo build recipes/zlib.yaml
# Build with custom work directory # Install package built by zsvo
pkg build -w /tmp/build zlib.toml zsvo install /path/to/name-version.pkg.tar.zst
```
### Installing a Package
```bash
# Install package
pkg install /path/to/package.pkg.tar.zst
# Install to custom root # 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 ```yaml
# List installed packages name: bash
pkg list version: "5.2"
# Show package information description: GNU Bourne Again SHell
pkg info zlib
# Remove package source:
pkg remove zlib 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 source:
name = "package-name" debian_dsc: https://deb.debian.org/debian/pool/main/b/bash/bash_5.2.37-2.dsc
version = "1.0.0" # optional: sha256 of the .dsc file itself
description = "Package description" # sha256: <dsc-sha256>
[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
``` ```
## 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: ## Architecture
- Installed files
- `.pkginfo` metadata file
## Package Database - `pkg/recipe` — YAML recipe parser.
- `pkg/fetcher` — download + checksum + extract + patch apply.
Installed packages are tracked in `/var/lib/pkgdb/<pkgname>/` with: - `pkg/builder` — build/install pipeline in isolated workdir.
- `.pkginfo` file containing package metadata - `pkg/packager` — create/read `name-version.pkg.tar.zst` from staging.
- File lists and installation information - `pkg/installer` — install/remove packages into `--root` with file safety checks and rollback.
## 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

View file

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

View file

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

View file

@ -19,19 +19,35 @@ var ListCmd = &cobra.Command{
} }
i := installer.NewInstaller(rootDir) i := installer.NewInstaller(rootDir)
orphansOnly, _ := cmd.Flags().GetBool("orphans")
// List installed packages var (
packages, err := i.ListInstalled() packages []string
err error
)
if orphansOnly {
packages, err = i.ListOrphans()
} else {
packages, err = i.ListInstalled()
}
if err != nil { if err != nil {
return fmt.Errorf("failed to list packages: %w", err) return fmt.Errorf("failed to list packages: %w", err)
} }
if len(packages) == 0 { if len(packages) == 0 {
if orphansOnly {
fmt.Println("No orphan packages")
} else {
fmt.Println("No packages installed") fmt.Println("No packages installed")
}
return nil return nil
} }
if orphansOnly {
fmt.Println("Orphan packages:")
} else {
fmt.Println("Installed packages:") fmt.Println("Installed packages:")
}
for _, pkg := range packages { for _, pkg := range packages {
fmt.Printf(" %s\n", pkg) fmt.Printf(" %s\n", pkg)
} }
@ -41,4 +57,5 @@ var ListCmd = &cobra.Command{
func init() { func init() {
ListCmd.Flags().StringP("root", "r", "/", "Root directory for installation") 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{ var RemoveCmd = &cobra.Command{
Use: "remove <package>", Use: "remove <package> [package...]",
Short: "Remove a package", Short: "Remove package(s)",
Long: `Remove an installed package`, Long: `Remove one or more installed packages`,
Args: cobra.ExactArgs(1), Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
packageName := args[0]
// Create installer // Create installer
rootDir, _ := cmd.Flags().GetString("root") rootDir, _ := cmd.Flags().GetString("root")
if rootDir == "" { if rootDir == "" {
@ -22,18 +20,45 @@ var RemoveCmd = &cobra.Command{
} }
i := installer.NewInstaller(rootDir) i := installer.NewInstaller(rootDir)
cascade, _ := cmd.Flags().GetBool("cascade")
// Remove package dryRun, _ := cmd.Flags().GetBool("dry-run")
fmt.Printf("Removing package %s...\n", packageName) options := installer.RemoveOptions{
if err := i.Remove(packageName); err != nil { Cascade: cascade,
return fmt.Errorf("failed to remove package: %w", err)
} }
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 return nil
}, },
} }
func init() { func init() {
RemoveCmd.Flags().StringP("root", "r", "/", "Root directory for installation") 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 go 1.23.0
require ( require (
github.com/BurntSushi/toml v1.3.2
github.com/mholt/archiver/v3 v3.5.1 github.com/mholt/archiver/v3 v3.5.1
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
golang.org/x/sync v0.12.0 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 h1:KqhlKozYbRtJvsPrrEeXcO+N2l6NYT5A2QAFmSULpEc=
github.com/andybalholm/brotli v1.0.1/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= 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= 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/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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/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{ var rootCmd = &cobra.Command{
Use: "pkg", Use: "zsvo",
Short: "A simple source-based package manager", Short: "A simple source-based package manager",
Long: `A minimal package manager for custom Linux distributions based on LFS Long: `A minimal package manager for custom Linux distributions based on LFS
Available commands: Available commands:
build Build a package from recipe build Build a package from recipe
install Install a package from package file install Install package(s) from package files
remove Remove an installed package upgrade Upgrade package(s) from package files
remove Remove installed package(s)
list List installed packages list List installed packages
info Show package information info Show package information
help Show help for a command help Show help for a command
@ -27,6 +28,7 @@ func init() {
// Register all commands // Register all commands
rootCmd.AddCommand(cmd.BuildCmd) rootCmd.AddCommand(cmd.BuildCmd)
rootCmd.AddCommand(cmd.InstallCmd) rootCmd.AddCommand(cmd.InstallCmd)
rootCmd.AddCommand(cmd.UpgradeCmd)
rootCmd.AddCommand(cmd.RemoveCmd) rootCmd.AddCommand(cmd.RemoveCmd)
rootCmd.AddCommand(cmd.ListCmd) rootCmd.AddCommand(cmd.ListCmd)
rootCmd.AddCommand(cmd.InfoCmd) rootCmd.AddCommand(cmd.InfoCmd)

View file

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

View file

@ -1,14 +1,19 @@
package fetcher package fetcher
import ( import (
"bufio"
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
neturl "net/url"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings"
"github.com/mholt/archiver/v3" "github.com/mholt/archiver/v3"
"golang.org/x/sync/errgroup"
) )
// Fetcher handles downloading and extracting sources // 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) { func (f *Fetcher) Download(url, expectedHash string) (string, error) {
// Validate input parameters // Validate input parameters
if url == "" { if url == "" {
return "", fmt.Errorf("URL cannot be empty") return "", fmt.Errorf("URL cannot be empty")
} }
if expectedHash == "" { expectedHash = strings.ToLower(strings.TrimSpace(expectedHash))
return "", fmt.Errorf("expected hash cannot be empty")
}
filename := filepath.Base(url) filename := filepath.Base(url)
if filename == "" { if filename == "" {
@ -40,10 +43,16 @@ func (f *Fetcher) Download(url, expectedHash string) (string, error) {
cachePath := filepath.Join(f.cacheDir, filename) 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) { if f.isValidCache(cachePath, expectedHash) {
return cachePath, nil return cachePath, nil
} }
} else {
if _, err := os.Stat(cachePath); err == nil {
return cachePath, nil
}
}
// Create cache directory if it doesn't exist // Create cache directory if it doesn't exist
if err := os.MkdirAll(f.cacheDir, 0755); err != nil { 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 { if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err) return "", fmt.Errorf("failed to create temp file: %w", err)
} }
defer tmpFile.Close()
// Download to temp file // Download to temp file
hasher := sha256.New() 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) 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 // Verify checksum
if expectedHash != "" {
calculatedHash := fmt.Sprintf("%x", hasher.Sum(nil)) calculatedHash := fmt.Sprintf("%x", hasher.Sum(nil))
if calculatedHash != expectedHash { if calculatedHash != expectedHash {
_ = os.Remove(tmpFile.Name())
return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, calculatedHash) return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, calculatedHash)
} }
}
// Move temp file to final location // Move temp file to final location
if err := os.Rename(tmpFile.Name(), cachePath); err != nil { if err := os.Rename(tmpFile.Name(), cachePath); err != nil {
_ = os.Remove(tmpFile.Name())
return "", fmt.Errorf("failed to move downloaded file: %w", err) 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 // applyPatch applies a single patch file
func (f *Fetcher) applyPatch(sourceDir, patchFile string) error { func (f *Fetcher) applyPatch(sourceDir, patchFile string) error {
// This is a simplified patch application patchFile = filepath.Clean(patchFile)
// 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 // Try common strip levels used by patch files.
patchDest := filepath.Join(sourceDir, filepath.Base(patchFile)) for _, stripLevel := range []string{"1", "0"} {
if err := copyFile(patchFile, patchDest); err != nil { cmd := exec.Command("patch", "-p"+stripLevel, "-i", patchFile)
return fmt.Errorf("failed to copy patch file: %w", err) cmd.Dir = sourceDir
} cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err == nil {
return 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 fmt.Errorf("failed to apply patch with -p1 and -p0: %s", patchFile)
return err
} }
// DownloadAndExtract downloads and extracts source // DownloadAndExtract downloads and extracts source
func (f *Fetcher) DownloadAndExtract(url, expectedHash, destDir string) error { func (f *Fetcher) DownloadAndExtract(url, expectedHash, destDir string) error {
if isDebianDSCURL(url) {
return f.downloadAndExtractFromDebianDSC(url, expectedHash, destDir)
}
archivePath, err := f.Download(url, expectedHash) archivePath, err := f.Download(url, expectedHash)
if err != nil { if err != nil {
return err return err
@ -166,6 +174,139 @@ func (f *Fetcher) DownloadAndExtract(url, expectedHash, destDir string) error {
return f.Extract(archivePath, destDir) 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 // DownloadMultiple downloads multiple files concurrently
func (f *Fetcher) DownloadMultiple(urls []string, hashes []string) ([]string, error) { func (f *Fetcher) DownloadMultiple(urls []string, hashes []string) ([]string, error) {
if len(urls) != len(hashes) { if len(urls) != len(hashes) {
@ -173,12 +314,21 @@ func (f *Fetcher) DownloadMultiple(urls []string, hashes []string) ([]string, er
} }
paths := make([]string, len(urls)) paths := make([]string, len(urls))
var g errgroup.Group
for i, url := range urls { for i, url := range urls {
i, url := i, url
g.Go(func() error {
path, err := f.Download(url, hashes[i]) path, err := f.Download(url, hashes[i])
if err != nil { if err != nil {
return nil, err return err
} }
paths[i] = path paths[i] = path
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
} }
return paths, nil 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 package packager
import ( import (
"bufio" "archive/tar"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/klauspost/compress/zstd"
"github.com/mholt/archiver/v3" "github.com/mholt/archiver/v3"
"zsvo/pkg/recipe" "zsvo/pkg/recipe"
"zsvo/pkg/types" "zsvo/pkg/types"
@ -74,19 +75,20 @@ func (p *Packager) Package(recipe *recipe.Recipe) error {
Name: recipe.Name, Name: recipe.Name,
Version: recipe.Version, Version: recipe.Version,
Description: recipe.Description, Description: recipe.Description,
Dependencies: recipe.Dependencies, Dependencies: recipe.Deps,
Files: files, Files: files,
InstallDate: time.Now().Format(time.RFC3339), InstallDate: time.Now().Format(time.RFC3339),
} }
// Create package info file // Create package info file
pkgInfoFile := filepath.Join(packageDir, ".pkginfo") pkgInfoFile := filepath.Join(stagingDir, types.PackageMetadataFile)
if err := p.writePkgInfo(pkgInfoFile, pkgInfo); err != nil { if err := p.writePkgInfo(pkgInfoFile, pkgInfo); err != nil {
return fmt.Errorf("failed to write package info to %s: %w", pkgInfoFile, err) return fmt.Errorf("failed to write package info to %s: %w", pkgInfoFile, err)
} }
defer os.Remove(pkgInfoFile)
// Create package archive // 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) 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() defer file.Close()
// Write package info in simple format return types.WritePkgInfo(file, pkgInfo)
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
} }
// createArchive creates a compressed archive // createArchive creates a compressed archive
func (p *Packager) createArchive(sourceDir, archivePath, pkgInfoFile string) error { func (p *Packager) createArchive(sourceDir, archivePath string) error {
// Create archive using archiver.Archive outFile, err := os.Create(archivePath)
paths := []string{sourceDir, pkgInfoFile} if err != nil {
return archiver.Archive(paths, archivePath) 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 // Extract extracts a package archive
@ -164,9 +222,9 @@ func (p *Packager) ReadPkgInfo(archivePath string) (*types.PkgInfo, error) {
} }
// Read package info // 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) { if _, err := os.Stat(pkgInfoFile); os.IsNotExist(err) {
return nil, fmt.Errorf("package info file not found in archive") 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() defer file.Close()
var pkgInfo types.PkgInfo return types.ReadPkgInfo(file)
// 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
} }
// VerifyPackage verifies package integrity // VerifyPackage verifies package integrity
@ -294,16 +292,3 @@ func (p *Packager) Clean(recipe *recipe.Recipe) error {
packageDir := p.GetPackageDir(recipe) packageDir := p.GetPackageDir(recipe)
return os.RemoveAll(packageDir) 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 package recipe
import ( import (
"bufio"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/BurntSushi/toml"
) )
// Recipe represents a package recipe // Recipe represents a package recipe.
type Recipe struct { type Recipe struct {
Name string `toml:"name"` Name string
Version string `toml:"version"` Version string
Description string `toml:"description,omitempty"` Description string
Source Source `toml:"source"` Source Source
Build Build `toml:"build"` Build []string
Package Package `toml:"package"` Install []string
Dependencies []string `toml:"dependencies,omitempty"` Deps []string
Options map[string]bool `toml:"options,omitempty"` Dir string
} }
// Source represents source information // Source represents source information.
type Source struct { type Source struct {
URL string `toml:"url"` URL string
Sha256 string `toml:"sha256"` Sha256 string
Patches []string `toml:"patches,omitempty"` DebianDSC string
Patches []string
} }
// Build represents build configuration // ParseRecipe parses a recipe from a YAML file.
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
func ParseRecipe(path string) (*Recipe, error) { func ParseRecipe(path string) (*Recipe, error) {
// Validate input path
if path == "" { if path == "" {
return nil, fmt.Errorf("recipe path cannot be empty") return nil, fmt.Errorf("recipe path cannot be empty")
} }
// Clean and normalize path
path = filepath.Clean(path) path = filepath.Clean(path)
file, err := os.Open(path) file, err := os.Open(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open recipe file %s: %w", path, err) return nil, fmt.Errorf("failed to open recipe file %s: %w", path, err)
} }
defer file.Close() 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) { func ParseRecipeFromReader(r io.Reader) (*Recipe, error) {
var recipe Recipe rcp := &Recipe{}
if _, err := toml.NewDecoder(r).Decode(&recipe); err != nil {
return nil, fmt.Errorf("failed to parse recipe: %w", err) 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") return nil, fmt.Errorf("recipe must have a name")
} }
if recipe.Version == "" { if rcp.Version == "" {
return nil, fmt.Errorf("recipe must have a version") return nil, fmt.Errorf("recipe must have a version")
} }
if recipe.Source.URL == "" { if rcp.Source.URL != "" && rcp.Source.DebianDSC != "" {
return nil, fmt.Errorf("recipe must have a source URL") 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") 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 nil, fmt.Errorf("recipe must have build commands")
} }
if len(rcp.Install) == 0 {
return nil, fmt.Errorf("recipe must have install commands")
}
return &recipe, nil 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
} }
// GetPackageName returns the package name in format name-version 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 { func (r *Recipe) GetPackageName() string {
return fmt.Sprintf("%s-%s", r.Name, r.Version) 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 { func (r *Recipe) GetPackageFileName() string {
return fmt.Sprintf("%s.pkg.tar.zst", r.GetPackageName()) 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 { func (r *Recipe) GetPackageDir(baseDir string) string {
return filepath.Join(baseDir, "packages", r.GetPackageName()) 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 { func (r *Recipe) GetSourceDir(baseDir string) string {
return filepath.Join(baseDir, "sources", r.GetPackageName()) 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 { func (r *Recipe) GetStagingDir(baseDir string) string {
return filepath.Join(baseDir, "staging", r.GetPackageName()) 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 package types
const PackageMetadataFile = ".zsvo.yml"
// PkgInfo represents package metadata // PkgInfo represents package metadata
type PkgInfo struct { type PkgInfo struct {
Name string `toml:"name"` Name string `yaml:"name"`
Version string `toml:"version"` Version string `yaml:"version"`
Description string `toml:"description,omitempty"` Description string `yaml:"description,omitempty"`
Dependencies []string `toml:"dependencies,omitempty"` Dependencies []string `yaml:"deps,omitempty"`
Files []string `toml:"files"` Files []string `yaml:"files"`
InstallDate string `toml:"install_date"` 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.