кал много кала......просто огромное количество кала....
This commit is contained in:
parent
e351ee62d1
commit
8db46a6b45
25 changed files with 2634 additions and 695 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -35,6 +35,7 @@ coverage.txt
|
||||||
# Dependency directories
|
# Dependency directories
|
||||||
vendor/
|
vendor/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
yay/
|
||||||
|
|
||||||
# Environment files
|
# Environment files
|
||||||
.env
|
.env
|
||||||
|
|
@ -45,4 +46,4 @@ node_modules/
|
||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.backup
|
||||||
*.orig
|
*.orig
|
||||||
|
|
|
||||||
197
README.md
197
README.md
|
|
@ -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
|
|
||||||
|
|
|
||||||
10
cmd/build.go
10
cmd/build.go
|
|
@ -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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
cmd/list.go
25
cmd/list.go
|
|
@ -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 {
|
||||||
fmt.Println("No packages installed")
|
if orphansOnly {
|
||||||
|
fmt.Println("No orphan packages")
|
||||||
|
} else {
|
||||||
|
fmt.Println("No packages installed")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Installed packages:")
|
if orphansOnly {
|
||||||
|
fmt.Println("Orphan packages:")
|
||||||
|
} else {
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
35
cmd/upgrade.go
Normal 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
1
go.mod
|
|
@ -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
3
go.sum
|
|
@ -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=
|
|
||||||
|
|
|
||||||
8
main.go
8
main.go
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,13 +141,13 @@ 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
|
||||||
|
|
||||||
|
|
@ -217,10 +246,10 @@ func (b *Builder) GetBuildInfo(recipe *recipe.Recipe) (*BuildInfo, error) {
|
||||||
stagingDir := recipe.GetStagingDir(b.workDir)
|
stagingDir := recipe.GetStagingDir(b.workDir)
|
||||||
|
|
||||||
info := &BuildInfo{
|
info := &BuildInfo{
|
||||||
Recipe: recipe,
|
Recipe: recipe,
|
||||||
SourceDir: sourceDir,
|
SourceDir: sourceDir,
|
||||||
StagingDir: stagingDir,
|
StagingDir: stagingDir,
|
||||||
SourceExists: false,
|
SourceExists: false,
|
||||||
StagingExists: false,
|
StagingExists: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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,13 +333,13 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,9 +43,15 @@ 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 f.isValidCache(cachePath, expectedHash) {
|
if expectedHash != "" {
|
||||||
return cachePath, nil
|
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
|
// Create cache directory if it doesn't exist
|
||||||
|
|
@ -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
|
||||||
calculatedHash := fmt.Sprintf("%x", hasher.Sum(nil))
|
if expectedHash != "" {
|
||||||
if calculatedHash != expectedHash {
|
calculatedHash := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||||
return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, calculatedHash)
|
if calculatedHash != expectedHash {
|
||||||
|
_ = os.Remove(tmpFile.Name())
|
||||||
|
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
|
|
||||||
patchDest := filepath.Join(sourceDir, filepath.Base(patchFile))
|
|
||||||
if err := copyFile(patchFile, patchDest); err != nil {
|
|
||||||
return fmt.Errorf("failed to copy patch file: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyFile copies a file from src to dst
|
// Try common strip levels used by patch files.
|
||||||
func copyFile(src, dst string) error {
|
for _, stripLevel := range []string{"1", "0"} {
|
||||||
sourceFile, err := os.Open(src)
|
cmd := exec.Command("patch", "-p"+stripLevel, "-i", patchFile)
|
||||||
if err != nil {
|
cmd.Dir = sourceDir
|
||||||
return err
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
defer sourceFile.Close()
|
|
||||||
|
|
||||||
destFile, err := os.Create(dst)
|
return fmt.Errorf("failed to apply patch with -p1 and -p0: %s", patchFile)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer destFile.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(destFile, sourceFile)
|
|
||||||
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 {
|
||||||
path, err := f.Download(url, hashes[i])
|
i, url := i, url
|
||||||
if err != nil {
|
g.Go(func() error {
|
||||||
return nil, err
|
path, err := f.Download(url, hashes[i])
|
||||||
}
|
if err != nil {
|
||||||
paths[i] = path
|
return err
|
||||||
|
}
|
||||||
|
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
147
pkg/fetcher/fetcher_test.go
Normal 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
261
pkg/installer/installer_test.go
Normal file
261
pkg/installer/installer_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
|
||||||
67
pkg/packager/packager_test.go
Normal file
67
pkg/packager/packager_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 recipe.Name == "" {
|
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
118
pkg/recipe/recipe_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
191
pkg/types/pkginfo_codec.go
Normal 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
|
||||||
|
}
|
||||||
34
pkg/types/pkginfo_codec_test.go
Normal file
34
pkg/types/pkginfo_codec_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
17
recipes/zlib.yaml
Normal 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
BIN
zsvo
Binary file not shown.
Loading…
Reference in a new issue