пися попа говно член пенис параша залуууууууупа сиси говно

This commit is contained in:
itexpert228 2026-03-13 11:56:08 +03:00
parent 2c7c0f877b
commit 05eabf12a7
No known key found for this signature in database
14 changed files with 1883 additions and 138 deletions

View file

@ -56,6 +56,8 @@ install:
deps:
- glibc
- readline
- "zlib>=1.2.13"
- "lua5.1 | luajit"
```
Debian upstream source mode:
@ -71,10 +73,14 @@ source:
```
Notes:
- `deps` are simple package names (no version solver yet).
- `deps` support full constraints: `name`, `name>=1.2`, `name (<= 2.0)`, and alternatives via `|`.
- Installer resolves dependencies with version checks and transaction ordering (topological sort), including mixed installed + transaction packages.
- `{{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`).
- `zsvo install <name>` auto-resolves Debian source over HTTP from Debian `Sources` indexes, builds package in `--work-dir` and installs it.
- Auto-build in `install <name>` is quiet by default and shows a colored live progress bar (spinner + percent + elapsed time) instead of raw compiler output; full command output tail is shown on failure.
- If build fails due to missing tools (`cmake`, `meson`, `pkg-config`, `lua` etc.), `zsvo install` auto-detects them, builds missing dependencies from Debian source with zsvo itself, installs them into `--work-dir/bootstrap-root`, and retries the build (`--auto-build-deps=false` to disable).
- Auto-build can be tuned with env flags: `ZSVO_AUTOGEN_ARGS`, `ZSVO_CONFIGURE_FLAGS`, `ZSVO_CMAKE_FLAGS`, `ZSVO_CMAKE_BUILD_FLAGS`, `ZSVO_MESON_SETUP_ARGS`, `ZSVO_MESON_COMPILE_ARGS`, `ZSVO_MESON_INSTALL_ARGS`, `ZSVO_MAKE_FLAGS`, `ZSVO_MAKE_INSTALL_FLAGS`.
- Package metadata is stored in `.zsvo.yml` (not `.PKGINFO`).
## Architecture

View file

@ -3,8 +3,12 @@ package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"zsvo/pkg/builder"
@ -28,13 +32,14 @@ var InstallCmd = &cobra.Command{
workDir = "/tmp/pkg-work"
}
autoSource, _ := cmd.Flags().GetBool("auto-source")
autoBuildDeps, _ := cmd.Flags().GetBool("auto-build-deps")
installTargets := make([]string, 0, len(args))
var resolver *debian.Resolver
var b *builder.Builder
i := installer.NewInstaller(rootDir)
var session *autoBuildSession
if autoSource {
resolver = debian.NewResolver()
b = builder.NewBuilder(workDir)
session = newAutoBuildSession(workDir, autoBuildDeps)
}
for _, target := range args {
@ -55,25 +60,13 @@ var InstallCmd = &cobra.Command{
)
}
fmt.Printf("Resolving Debian source for %s...\n", target)
srcInfo, err := resolver.ResolveSource(target)
builtPackage, err := session.buildPackage(target, false, nil)
if err != nil {
return err
}
rcp := autoRecipeFromDebian(srcInfo)
fmt.Printf("Building %s from %s...\n", rcp.GetPackageName(), srcInfo.DSCURL)
if err := b.Build(rcp); err != nil {
return fmt.Errorf("failed to auto-build %s: %w", target, err)
}
builtPackage := filepath.Join(rcp.GetPackageDir(workDir), rcp.GetPackageFileName())
fmt.Printf("Built package: %s\n", builtPackage)
installTargets = append(installTargets, builtPackage)
}
i := installer.NewInstaller(rootDir)
if len(installTargets) == 1 {
fmt.Printf("Installing package from %s...\n", installTargets[0])
} else {
@ -92,6 +85,7 @@ func init() {
InstallCmd.Flags().StringP("root", "r", "/", "Root directory for installation")
InstallCmd.Flags().StringP("work-dir", "w", "/tmp/pkg-work", "Working directory for source builds")
InstallCmd.Flags().Bool("auto-source", true, "Auto-build package names from Debian source")
InstallCmd.Flags().Bool("auto-build-deps", true, "Auto-build missing source build dependencies through zsvo")
}
func isInstallFileTarget(target string) (bool, error) {
@ -130,6 +124,375 @@ func looksLikeFilePath(target string) bool {
strings.HasSuffix(target, ".zov")
}
const maxAutoBuildDepth = 16
type autoBuildSession struct {
workDir string
toolRoot string
autoBuildDeps bool
resolver *debian.Resolver
builder *builder.Builder
toolInstaller *installer.Installer
builtPackages map[string]string
toolDepsReady map[string]struct{}
buildingPackages map[string]struct{}
}
func newAutoBuildSession(workDir string, autoBuildDeps bool) *autoBuildSession {
b := builder.NewBuilder(workDir)
b.SetQuiet(true)
s := &autoBuildSession{
workDir: workDir,
toolRoot: filepath.Join(workDir, "bootstrap-root"),
autoBuildDeps: autoBuildDeps,
resolver: debian.NewResolver(),
builder: b,
toolInstaller: installer.NewInstaller(filepath.Join(workDir, "bootstrap-root")),
builtPackages: make(map[string]string),
toolDepsReady: make(map[string]struct{}),
buildingPackages: make(map[string]struct{}),
}
s.refreshBuildEnv()
return s
}
func (s *autoBuildSession) refreshBuildEnv() {
basePath := splitPathList(os.Getenv("PATH"))
binPrefixes := []string{
filepath.Join(s.toolRoot, "usr", "bin"),
filepath.Join(s.toolRoot, "bin"),
filepath.Join(s.toolRoot, "usr", "sbin"),
filepath.Join(s.toolRoot, "sbin"),
}
mergedPath := joinPathListUnique(append(binPrefixes, basePath...))
s.builder.SetEnvOverride("PATH", mergedPath)
pkgConfigPath := splitPathList(os.Getenv("PKG_CONFIG_PATH"))
pkgConfigPrefixes := []string{
filepath.Join(s.toolRoot, "usr", "lib", "pkgconfig"),
filepath.Join(s.toolRoot, "usr", "lib64", "pkgconfig"),
filepath.Join(s.toolRoot, "usr", "share", "pkgconfig"),
filepath.Join(s.toolRoot, "lib", "pkgconfig"),
filepath.Join(s.toolRoot, "lib64", "pkgconfig"),
}
s.builder.SetEnvOverride("PKG_CONFIG_PATH", joinPathListUnique(append(pkgConfigPrefixes, pkgConfigPath...)))
cmakePrefixes := []string{
filepath.Join(s.toolRoot, "usr"),
filepath.Join(s.toolRoot),
}
cmakePrefixes = append(cmakePrefixes, splitPathList(os.Getenv("CMAKE_PREFIX_PATH"))...)
s.builder.SetEnvOverride("CMAKE_PREFIX_PATH", joinPathListUnique(cmakePrefixes))
}
func (s *autoBuildSession) buildPackage(requestName string, asBuildDep bool, stack []string) (string, error) {
requestName = normalizePackageName(requestName)
if requestName == "" {
return "", fmt.Errorf("invalid package name")
}
if len(stack) >= maxAutoBuildDepth {
return "", fmt.Errorf("dependency chain is too deep while building %s: %s", requestName, strings.Join(append(stack, requestName), " -> "))
}
if _, exists := s.buildingPackages[requestName]; exists {
return "", fmt.Errorf("dependency cycle detected: %s", strings.Join(append(stack, requestName), " -> "))
}
if builtPath, ok := s.builtPackages[requestName]; ok {
if asBuildDep {
if err := s.installBuildDependency(requestName, builtPath); err != nil {
return "", err
}
}
return builtPath, nil
}
s.buildingPackages[requestName] = struct{}{}
defer delete(s.buildingPackages, requestName)
fmt.Printf("Resolving Debian source for %s...\n", requestName)
srcInfo, err := s.resolver.ResolveSource(requestName)
if err != nil {
return "", fmt.Errorf("failed to resolve source for %s: %w", requestName, err)
}
rcp := autoRecipeFromDebian(srcInfo)
normalizedRecipeName := normalizePackageName(rcp.Name)
if normalizedRecipeName != "" && normalizedRecipeName != requestName {
// Alias the resolved source package name to the requested name.
if _, exists := s.buildingPackages[normalizedRecipeName]; exists {
return "", fmt.Errorf("dependency cycle detected: %s", strings.Join(append(stack, requestName, normalizedRecipeName), " -> "))
}
}
fmt.Printf("Building %s from %s...\n", rcp.GetPackageName(), srcInfo.DSCURL)
var buildErr error
for attempt := 0; attempt < 2; attempt++ {
bar := newProgressUI(rcp.Name)
s.builder.SetProgressCallback(func(p builder.BuildProgress) {
bar.update(p.Step, p.Total, fmt.Sprintf("%s: %s", rcp.Name, p.Message))
})
buildErr = s.builder.Build(rcp)
s.builder.SetProgressCallback(nil)
if buildErr == nil {
bar.finish(true, fmt.Sprintf("%s: complete", rcp.Name))
break
}
bar.finish(false, fmt.Sprintf("%s: failed", rcp.Name))
if !s.autoBuildDeps || attempt == 1 {
hint := buildFailureHint(requestName, buildErr)
if hint != "" {
return "", fmt.Errorf("failed to auto-build %s: %w\n%s", requestName, buildErr, hint)
}
return "", fmt.Errorf("failed to auto-build %s: %w", requestName, buildErr)
}
missingDeps := inferMissingBuildDeps(buildErr)
if len(missingDeps) == 0 {
hint := buildFailureHint(requestName, buildErr)
if hint != "" {
return "", fmt.Errorf("failed to auto-build %s: %w\n%s", requestName, buildErr, hint)
}
return "", fmt.Errorf("failed to auto-build %s: %w", requestName, buildErr)
}
fmt.Printf("Detected missing build dependencies for %s: %s\n", requestName, strings.Join(missingDeps, ", "))
for _, dep := range missingDeps {
if dep == requestName || dep == normalizedRecipeName {
continue
}
if _, ready := s.toolDepsReady[dep]; ready {
continue
}
if err := s.ensureBuildDependency(dep, append(stack, requestName)); err != nil {
return "", fmt.Errorf("failed to satisfy build dependency %s for %s: %w", dep, requestName, err)
}
}
fmt.Printf("Retrying build for %s after auto-installing dependencies...\n", requestName)
}
if buildErr != nil {
return "", fmt.Errorf("failed to auto-build %s: %w", requestName, buildErr)
}
builtPackage := filepath.Join(rcp.GetPackageDir(s.workDir), rcp.GetPackageFileName())
fmt.Printf("Built package: %s\n", builtPackage)
s.builtPackages[requestName] = builtPackage
if normalizedRecipeName != "" {
s.builtPackages[normalizedRecipeName] = builtPackage
}
if asBuildDep {
if err := s.installBuildDependency(requestName, builtPackage); err != nil {
return "", err
}
}
return builtPackage, nil
}
func (s *autoBuildSession) ensureBuildDependency(dep string, stack []string) error {
dep = normalizePackageName(dep)
if dep == "" {
return fmt.Errorf("invalid build dependency name")
}
if _, ready := s.toolDepsReady[dep]; ready {
return nil
}
if toolAlreadyAvailable(dep) {
s.toolDepsReady[dep] = struct{}{}
return nil
}
packagePath, err := s.buildPackage(dep, true, stack)
if err != nil {
return err
}
return s.installBuildDependency(dep, packagePath)
}
func (s *autoBuildSession) installBuildDependency(dep, packagePath string) error {
dep = normalizePackageName(dep)
if dep == "" {
return fmt.Errorf("invalid build dependency name")
}
if _, ready := s.toolDepsReady[dep]; ready {
return nil
}
fmt.Printf("Installing build dependency %s into %s...\n", dep, s.toolRoot)
if err := s.toolInstaller.Install(packagePath); err != nil {
return fmt.Errorf("failed to install build dependency %s: %w", dep, err)
}
s.toolDepsReady[dep] = struct{}{}
s.refreshBuildEnv()
return nil
}
func splitPathList(value string) []string {
parts := strings.Split(value, string(os.PathListSeparator))
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
}
func joinPathListUnique(parts []string) string {
seen := make(map[string]struct{}, len(parts))
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if _, exists := seen[part]; exists {
continue
}
seen[part] = struct{}{}
out = append(out, part)
}
return strings.Join(out, string(os.PathListSeparator))
}
var simplePkgNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]*$`)
var missingCommandPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?m)(?:^|[\s:])(?:/bin/)?sh:\s*(?:\d+:\s*)?([a-zA-Z0-9+_.-]+):\s*(?:command not found|not found)\b`),
regexp.MustCompile(`(?m)\b([a-zA-Z0-9+_.-]+):\s*command not found\b`),
regexp.MustCompile(`(?m)\b([a-zA-Z0-9+_.-]+):\s*not found\b`),
}
func inferMissingBuildDeps(err error) []string {
if err == nil {
return nil
}
text := err.Error()
lowerText := strings.ToLower(text)
found := make(map[string]struct{})
for _, pattern := range missingCommandPatterns {
matches := pattern.FindAllStringSubmatch(text, -1)
for _, match := range matches {
if len(match) < 2 {
continue
}
pkg := mapToolToSourcePackage(match[1])
if pkg == "" {
continue
}
found[pkg] = struct{}{}
}
}
if strings.Contains(lowerText, "failed to find a lua 5.1-compatible interpreter") {
found["lua5.1"] = struct{}{}
}
if strings.Contains(lowerText, "pkg-config") &&
(strings.Contains(lowerText, "not found") || strings.Contains(lowerText, "could not find")) {
found["pkgconf"] = struct{}{}
}
if strings.Contains(lowerText, "no acceptable c compiler found in $path") ||
strings.Contains(lowerText, "c compiler cannot create executables") {
found["gcc"] = struct{}{}
}
if len(found) == 0 {
return nil
}
deps := make([]string, 0, len(found))
for dep := range found {
deps = append(deps, dep)
}
sort.Strings(deps)
return deps
}
func mapToolToSourcePackage(tool string) string {
tool = normalizePackageName(tool)
if tool == "" || allDigits(tool) {
return ""
}
switch tool {
case "sh", "bash", "dash", "zsh":
return ""
case "pkg-config":
return "pkgconf"
case "ninja":
return "ninja-build"
case "python":
return "python3"
case "lua":
return "lua5.1"
case "luajit":
return "luajit"
case "cc", "c++", "g++", "gcc":
return "gcc"
case "ld":
return "binutils"
case "xzcat":
return "xz-utils"
}
if !simplePkgNamePattern.MatchString(tool) {
return ""
}
return tool
}
func allDigits(s string) bool {
if s == "" {
return false
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func normalizePackageName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
var commandHintsByDep = map[string][]string{
"pkgconf": {"pkg-config"},
"ninja-build": {"ninja"},
"python3": {"python3", "python"},
"lua5.1": {"lua", "lua5.1"},
}
func toolAlreadyAvailable(dep string) bool {
dep = normalizePackageName(dep)
if dep == "" {
return false
}
commands := commandHintsByDep[dep]
if len(commands) == 0 {
commands = []string{dep}
}
for _, name := range commands {
if _, err := exec.LookPath(name); err == nil {
return true
}
}
return false
}
func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe {
name := src.SourcePackage
if name == "" {
@ -142,8 +505,8 @@ func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe {
installCmd := fmt.Sprintf(
"if [ -f build/cmake_install.cmake ]; then DESTDIR={{pkgdir}} cmake --install build; "+
"elif [ -f build/meson-private/coredata.dat ]; then DESTDIR={{pkgdir}} meson install -C build; "+
"elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make DESTDIR={{pkgdir}} PREFIX=/usr install; "+
"elif [ -f build/meson-private/coredata.dat ]; then DESTDIR={{pkgdir}} meson install -C build ${ZSVO_MESON_INSTALL_ARGS}; "+
"elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make DESTDIR={{pkgdir}} PREFIX=/usr ${ZSVO_MAKE_INSTALL_FLAGS} install; "+
"elif [ -f %s ]; then install -Dm755 %s {{pkgdir}}/usr/bin/%s; fi",
name,
name,
@ -159,11 +522,209 @@ func autoRecipeFromDebian(src *debian.SourceInfo) *recipe.Recipe {
Sha256: src.DSCSHA256,
},
Build: []string{
"[ -f configure ] && ./configure --prefix=/usr || true",
"if [ -f CMakeLists.txt ]; then cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr && cmake --build build -j${jobs}; " +
"elif [ -f meson.build ]; then meson setup build --prefix=/usr && meson compile -C build -j${jobs}; " +
"elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make -j${jobs}; fi",
"if [ ! -f configure ] && [ -f autogen.sh ]; then sh ./autogen.sh --no-check ${ZSVO_AUTOGEN_ARGS}; fi; if [ -f configure ]; then ./configure --prefix=/usr ${ZSVO_CONFIGURE_FLAGS}; fi",
"if [ -f CMakeLists.txt ]; then cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr ${ZSVO_CMAKE_FLAGS} && cmake --build build -j${jobs} -- ${ZSVO_CMAKE_BUILD_FLAGS}; " +
"elif [ -f meson.build ]; then meson setup build --prefix=/usr ${ZSVO_MESON_SETUP_ARGS} && meson compile -C build -j${jobs} ${ZSVO_MESON_COMPILE_ARGS}; " +
"elif [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then make -j${jobs} ${ZSVO_MAKE_FLAGS}; fi",
},
Install: []string{installCmd},
}
}
type progressUI struct {
total int
pkgName string
startedAt time.Time
enabled bool
frameIdx int
lastLineLen int
}
func newProgressUI(pkgName string) *progressUI {
return &progressUI{
pkgName: pkgName,
startedAt: time.Now(),
enabled: supportsANSIAndTTY(),
}
}
func (p *progressUI) update(step, total int, message string) {
if total <= 0 {
total = 1
}
if step < 0 {
step = 0
}
if step > total {
step = total
}
p.total = total
if !p.enabled {
fmt.Printf("[%d/%d] %s\n", step, total, message)
return
}
const width = 32
filled := step * width / total
if filled > width {
filled = width
}
percent := step * 100 / total
spinner := progressFrames[p.frameIdx%len(progressFrames)]
p.frameIdx++
bar := renderColoredBar(width, filled)
elapsed := formatElapsed(time.Since(p.startedAt))
line := fmt.Sprintf(
"\r%s %s %s %3d%% %s %s",
colorize("36;1", spinner),
colorize("1", p.pkgName),
bar,
percent,
colorize("2", "| "+truncateText(message, 48)),
colorize("2", elapsed),
)
if pad := p.lastLineLen - visibleLen(line); pad > 0 {
line += strings.Repeat(" ", pad)
}
p.lastLineLen = visibleLen(line)
fmt.Print(line)
}
func (p *progressUI) finish(ok bool, message string) {
total := p.total
if total <= 0 {
total = 1
}
p.update(total, total, message)
if p.enabled {
status := colorize("31;1", "FAIL")
if ok {
status = colorize("32;1", "DONE")
}
fmt.Printf(" %s %s\n", status, colorize("2", "("+formatElapsed(time.Since(p.startedAt))+")"))
return
}
fmt.Println()
}
var progressFrames = []string{"|", "/", "-", "\\"}
func renderColoredBar(width, filled int) string {
if width <= 0 {
return "[]"
}
if filled < 0 {
filled = 0
}
if filled > width {
filled = width
}
done := strings.Repeat("=", filled)
todo := strings.Repeat(".", width-filled)
return "[" + colorize("32", done) + colorize("2", todo) + "]"
}
func supportsANSIAndTTY() bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
term := strings.TrimSpace(strings.ToLower(os.Getenv("TERM")))
if term == "" || term == "dumb" {
return false
}
info, err := os.Stdout.Stat()
if err != nil {
return false
}
return (info.Mode() & os.ModeCharDevice) != 0
}
func colorize(code, text string) string {
if text == "" {
return ""
}
return "\x1b[" + code + "m" + text + "\x1b[0m"
}
func formatElapsed(d time.Duration) string {
if d < 0 {
d = 0
}
totalSeconds := int(d.Seconds())
minutes := totalSeconds / 60
seconds := totalSeconds % 60
return fmt.Sprintf("%02d:%02d", minutes, seconds)
}
func truncateText(s string, max int) string {
if max <= 3 || len(s) <= max {
return s
}
return s[:max-3] + "..."
}
func visibleLen(s string) int {
// This is enough here because we only inject ANSI codes ourselves.
n := 0
inEsc := false
for i := 0; i < len(s); i++ {
ch := s[i]
if inEsc {
if ch == 'm' {
inEsc = false
}
continue
}
if ch == 0x1b {
inEsc = true
continue
}
n++
}
return n
}
func buildFailureHint(pkgName string, err error) string {
if err == nil {
return ""
}
text := strings.ToLower(err.Error())
pkgName = strings.TrimSpace(strings.ToLower(pkgName))
hints := make([]string, 0, 4)
missingDeps := inferMissingBuildDeps(err)
if len(missingDeps) > 0 {
hints = append(
hints,
fmt.Sprintf("Hint: missing build dependencies detected: %s.", strings.Join(missingDeps, ", ")),
"Hint: добавь рецепты для этих пакетов (или алиасы к Debian source), затем повтори `zsvo install`.",
)
}
if strings.Contains(text, "on systems using dpkg and apt, try: \"apt-get install package\"") {
hints = append(hints,
"Hint: upstream configure-script ожидает системные build-зависимости; в zsvo это нужно решать рецептами/автосборкой зависимостей.",
)
}
if pkgName == "neovim" && strings.Contains(text, "lua") {
hints = append(hints,
"Hint (neovim): после установки Lua можно зафиксировать интерпретатор: `export ZSVO_CMAKE_FLAGS=\"-DLUA_PRG=$(which lua) -DLUA_GEN_PRG=$(which lua)\"`.",
)
}
if len(hints) == 0 {
return ""
}
return strings.Join(hints, "\n")
}

View file

@ -1,8 +1,11 @@
package cmd
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"zsvo/pkg/debian"
@ -68,4 +71,59 @@ func TestAutoRecipeFromDebian(t *testing.T) {
if len(r.Build) == 0 || len(r.Install) == 0 {
t.Fatalf("expected auto recipe build/install commands")
}
if !strings.Contains(r.Build[0], "autogen.sh --no-check") {
t.Fatalf("expected auto recipe to support autogen fallback, got: %s", r.Build[0])
}
}
func TestBuildFailureHintForLua(t *testing.T) {
t.Parallel()
msg := buildFailureHint("neovim", errors.New("Failed to find a Lua 5.1-compatible interpreter"))
if !strings.Contains(strings.ToLower(msg), "lua") {
t.Fatalf("expected lua hint, got: %q", msg)
}
}
func TestInferMissingBuildDeps_FromCommandNotFound(t *testing.T) {
t.Parallel()
err := errors.New("sh: cmake: command not found\n/bin/sh: 1: pkg-config: not found")
got := inferMissingBuildDeps(err)
want := []string{"cmake", "pkgconf"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("inferMissingBuildDeps() mismatch:\nwant: %#v\ngot: %#v", want, got)
}
}
func TestInferMissingBuildDeps_FromLuaError(t *testing.T) {
t.Parallel()
err := errors.New("CMake Error: Failed to find a Lua 5.1-compatible interpreter")
got := inferMissingBuildDeps(err)
want := []string{"lua5.1"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("inferMissingBuildDeps() mismatch:\nwant: %#v\ngot: %#v", want, got)
}
}
func TestMapToolToSourcePackage(t *testing.T) {
t.Parallel()
cases := map[string]string{
"pkg-config": "pkgconf",
"ninja": "ninja-build",
"python": "python3",
"lua": "lua5.1",
"cmake": "cmake",
"bash": "",
"1": "",
"": "",
}
for input, want := range cases {
if got := mapToolToSourcePackage(input); got != want {
t.Fatalf("mapToolToSourcePackage(%q): want %q, got %q", input, want, got)
}
}
}

View file

@ -11,6 +11,8 @@ import (
var rootCmd = &cobra.Command{
Use: "zsvo",
Short: "A simple source-based package manager",
SilenceErrors: true,
SilenceUsage: true,
Long: `A minimal package manager for custom Linux distributions based on LFS
Available commands:

View file

@ -16,6 +16,16 @@ import (
// Builder handles building packages from recipes
type Builder struct {
workDir string
quiet bool
progressCallback func(BuildProgress)
envOverrides map[string]string
}
// BuildProgress represents build progress state.
type BuildProgress struct {
Step int
Total int
Message string
}
// NewBuilder creates a new builder
@ -31,36 +41,51 @@ func (b *Builder) Build(recipe *recipe.Recipe) error {
return fmt.Errorf("recipe cannot be nil")
}
totalSteps := 4 + len(recipe.Build) + len(recipe.Install)
step := 0
nextStep := func(message string) {
step++
b.reportProgress(step, totalSteps, message)
}
// Create working directories
sourceDir := recipe.GetSourceDir(b.workDir)
stagingDir := recipe.GetStagingDir(b.workDir)
packageDir := recipe.GetPackageDir(b.workDir)
nextStep("Preparing directories")
if err := b.prepareDirectories(sourceDir, stagingDir, packageDir); err != nil {
return fmt.Errorf("failed to prepare directories: %w", err)
}
// Download and extract source
nextStep("Downloading and extracting source")
if err := b.downloadAndExtract(recipe); err != nil {
return fmt.Errorf("failed to download and extract source: %w", err)
}
// Apply patches
nextStep("Applying recipe patches")
if err := b.applyPatches(recipe, sourceDir); err != nil {
return fmt.Errorf("failed to apply patches: %w", err)
}
// Build package
if err := b.executeBuild(recipe, sourceDir, stagingDir); err != nil {
if err := b.executeBuild(recipe, sourceDir, stagingDir, func(i, total int) {
nextStep(fmt.Sprintf("Build step %d/%d", i, total))
}); err != nil {
return fmt.Errorf("failed to build package: %w", err)
}
// Package files
if err := b.packageFiles(recipe, sourceDir, stagingDir); err != nil {
if err := b.packageFiles(recipe, sourceDir, stagingDir, func(i, total int) {
nextStep(fmt.Sprintf("Install step %d/%d", i, total))
}); err != nil {
return fmt.Errorf("failed to package files: %w", err)
}
// Create package archive
nextStep("Creating package archive")
p := packager.NewPackager(b.workDir)
if err := p.Package(recipe); err != nil {
return fmt.Errorf("failed to create package archive: %w", err)
@ -133,7 +158,7 @@ func (b *Builder) applyPatches(recipe *recipe.Recipe, sourceDir string) error {
}
// executeBuild executes build commands
func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir string) error {
func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir string, progressFn func(step, total int)) error {
// Find the source directory (usually the first subdirectory)
srcPath, err := b.findSourceDirectory(sourceDir)
if err != nil {
@ -145,6 +170,10 @@ func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir stri
// Execute build commands
for i, cmd := range recipe.Build {
if progressFn != nil {
progressFn(i+1, len(recipe.Build))
}
// Substitute variables in command
cmd = b.substituteVariables(cmd, sourceDir, stagingDir)
@ -192,7 +221,7 @@ func (b *Builder) buildEnvironment(stagingDir string) []string {
// Add parallel build variable
env = append(env, fmt.Sprintf("MAKEFLAGS=-j%d", runtime.NumCPU()))
return env
return applyEnvOverrides(env, b.envOverrides)
}
// executeCommand executes a single command
@ -204,6 +233,19 @@ func (b *Builder) executeCommand(workDir, command string, env []string) error {
cmd := exec.Command("sh", "-c", command)
cmd.Dir = workDir
cmd.Env = env
if b.quiet {
output, err := cmd.CombinedOutput()
if err != nil {
details := tailOutput(string(output), 20)
if details != "" {
return fmt.Errorf("%w\n%s", err, details)
}
return err
}
return nil
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@ -308,6 +350,53 @@ func (b *Builder) SetWorkDir(workDir string) {
b.workDir = workDir
}
// SetQuiet enables or disables command output streaming.
func (b *Builder) SetQuiet(quiet bool) {
b.quiet = quiet
}
// SetProgressCallback sets callback for build progress updates.
func (b *Builder) SetProgressCallback(callback func(BuildProgress)) {
b.progressCallback = callback
}
// SetEnvOverride sets or removes one environment override used for build commands.
func (b *Builder) SetEnvOverride(key, value string) {
key = strings.TrimSpace(key)
if key == "" {
return
}
if b.envOverrides == nil {
b.envOverrides = make(map[string]string)
}
if value == "" {
delete(b.envOverrides, key)
return
}
b.envOverrides[key] = value
}
// SetEnvOverrides replaces all environment overrides used for build commands.
func (b *Builder) SetEnvOverrides(overrides map[string]string) {
if len(overrides) == 0 {
b.envOverrides = nil
return
}
cloned := make(map[string]string, len(overrides))
for key, value := range overrides {
key = strings.TrimSpace(key)
if key == "" || value == "" {
continue
}
cloned[key] = value
}
if len(cloned) == 0 {
b.envOverrides = nil
return
}
b.envOverrides = cloned
}
// substituteVariables substitutes variables in command strings
func (b *Builder) substituteVariables(cmdStr, sourceDir, stagingDir string) string {
// Get number of CPU cores
@ -325,7 +414,7 @@ func (b *Builder) substituteVariables(cmdStr, sourceDir, stagingDir string) stri
}
// packageFiles runs the package commands
func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir string) error {
func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir string, progressFn func(step, total int)) error {
// Find the source directory (usually the first subdirectory)
srcPath, err := b.findSourceDirectory(sourceDir)
if err != nil {
@ -337,6 +426,10 @@ func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir stri
// Execute package commands
for i, cmd := range recipe.Install {
if progressFn != nil {
progressFn(i+1, len(recipe.Install))
}
// Substitute variables in command
cmd = b.substituteVariables(cmd, sourceDir, stagingDir)
@ -347,3 +440,53 @@ func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir stri
return nil
}
func (b *Builder) reportProgress(step, total int, message string) {
if b.progressCallback == nil {
return
}
b.progressCallback(BuildProgress{
Step: step,
Total: total,
Message: message,
})
}
func tailOutput(output string, maxLines int) string {
output = strings.TrimSpace(output)
if output == "" {
return ""
}
lines := strings.Split(output, "\n")
if maxLines <= 0 || len(lines) <= maxLines {
return output
}
return strings.Join(lines[len(lines)-maxLines:], "\n")
}
func applyEnvOverrides(base []string, overrides map[string]string) []string {
if len(overrides) == 0 {
return base
}
out := append([]string(nil), base...)
indexByKey := make(map[string]int, len(out))
for i, entry := range out {
if eq := strings.IndexByte(entry, '='); eq > 0 {
indexByKey[entry[:eq]] = i
}
}
for key, value := range overrides {
item := key + "=" + value
if idx, ok := indexByKey[key]; ok {
out[idx] = item
continue
}
indexByKey[key] = len(out)
out = append(out, item)
}
return out
}

View file

@ -144,6 +144,7 @@ type sourceRecord struct {
Directory string
DSCName string
DSCSHA256 string
Binaries []string
}
func (r *Resolver) findPackageInIndex(mirror, suite, component, pkg string) (*sourceRecord, error) {
@ -203,7 +204,7 @@ func (r *Resolver) findInSingleIndex(indexURL string, decoder func(io.Reader) (i
if err != nil {
return nil, false, nil
}
if rec.Package == pkg {
if rec.Package == pkg || containsSourceBinary(rec.Binaries, pkg) {
return &rec, true, nil
}
return nil, false, nil
@ -294,9 +295,41 @@ func parseSourcesParagraph(lines []string) (sourceRecord, error) {
Directory: dir,
DSCName: dscName,
DSCSHA256: dscHash,
Binaries: parseCommaSeparatedField(fields["Binary"]),
}, nil
}
func parseCommaSeparatedField(raw string) []string {
parts := strings.Split(strings.TrimSpace(raw), ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
part = strings.TrimSpace(strings.ToLower(part))
if part == "" {
continue
}
if _, exists := seen[part]; exists {
continue
}
seen[part] = struct{}{}
out = append(out, part)
}
return out
}
func containsSourceBinary(names []string, wanted string) bool {
wanted = strings.TrimSpace(strings.ToLower(wanted))
if wanted == "" {
return false
}
for _, name := range names {
if strings.TrimSpace(strings.ToLower(name)) == wanted {
return true
}
}
return false
}
func parseChecksumsForDSC(raw string) (string, string) {
for _, line := range strings.Split(raw, "\n") {
fields := strings.Fields(strings.TrimSpace(line))

View file

@ -21,6 +21,7 @@ func TestResolveSourceHTTPFallbackToGzip(t *testing.T) {
sources := strings.TrimSpace(`
Package: neofetch
Binary: neofetch
Version: 7.1.0-4
Directory: pool/main/n/neofetch
Checksums-Sha256:
@ -77,6 +78,48 @@ Checksums-Sha256:
}
}
func TestResolveSourceByBinaryName(t *testing.T) {
t.Parallel()
sources := strings.TrimSpace(`
Package: pkgconf
Binary: pkgconf, pkg-config
Version: 2.0.3-1
Directory: pool/main/p/pkgconf
Checksums-Sha256:
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc 1800 pkgconf_2.0.3-1.dsc
`) + "\n"
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.HasSuffix(req.URL.Path, "/Sources") {
return httpResponse(req, http.StatusOK, []byte(sources)), nil
}
return httpResponse(req, http.StatusNotFound, []byte("missing")), nil
}),
}
r := NewResolver(
WithHTTPClient(client),
WithMirrors([]string{"https://mirror.example/debian"}),
WithSuites([]string{"stable"}),
WithComponents([]string{"main"}),
)
info, err := r.ResolveSource("pkg-config")
if err != nil {
t.Fatalf("ResolveSource() error = %v", err)
}
if info.SourcePackage != "pkgconf" {
t.Fatalf("unexpected source package: %s", info.SourcePackage)
}
wantURL := "https://mirror.example/debian/pool/main/p/pkgconf/pkgconf_2.0.3-1.dsc"
if info.DSCURL != wantURL {
t.Fatalf("unexpected dsc url:\nwant: %s\ngot: %s", wantURL, info.DSCURL)
}
}
func TestResolveSourceHTTPNotFound(t *testing.T) {
t.Parallel()

411
pkg/deps/deps.go Normal file
View file

@ -0,0 +1,411 @@
package deps
import (
"fmt"
"strconv"
"strings"
"unicode"
)
// VersionOp defines version comparison operator for dependency constraints.
type VersionOp int
const (
OpAny VersionOp = iota
OpEqual
OpGreater
OpGreaterOrEqual
OpLess
OpLessOrEqual
)
// Constraint describes one dependency alternative like "pkg>=1.2".
type Constraint struct {
Name string
Op VersionOp
Version string
}
// Requirement describes one dependency expression with alternatives: "a | b>=2".
type Requirement struct {
Raw string
Alternatives []Constraint
}
// ParseRequirements parses dependency expressions.
func ParseRequirements(raw []string) ([]Requirement, error) {
reqs := make([]Requirement, 0, len(raw))
for _, entry := range raw {
req, err := ParseRequirement(entry)
if err != nil {
return nil, err
}
reqs = append(reqs, req)
}
return reqs, nil
}
// ParseRequirement parses one dependency expression.
func ParseRequirement(raw string) (Requirement, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return Requirement{}, fmt.Errorf("dependency cannot be empty")
}
parts := strings.Split(raw, "|")
alts := make([]Constraint, 0, len(parts))
for _, part := range parts {
c, err := parseConstraint(part)
if err != nil {
return Requirement{}, fmt.Errorf("invalid dependency %q: %w", raw, err)
}
alts = append(alts, c)
}
if len(alts) == 0 {
return Requirement{}, fmt.Errorf("invalid dependency %q", raw)
}
return Requirement{
Raw: raw,
Alternatives: alts,
}, nil
}
func parseConstraint(raw string) (Constraint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return Constraint{}, fmt.Errorf("empty alternative")
}
// Debian style: "pkg (>= 1.2)".
if open := strings.IndexByte(raw, '('); open >= 0 {
close := strings.LastIndexByte(raw, ')')
if close < open || close != len(raw)-1 {
return Constraint{}, fmt.Errorf("invalid parentheses syntax")
}
name := strings.TrimSpace(raw[:open])
if !isValidDepName(name) {
return Constraint{}, fmt.Errorf("invalid package name %q", name)
}
inner := strings.TrimSpace(raw[open+1 : close])
op, version, err := splitVersionConstraint(inner)
if err != nil {
return Constraint{}, err
}
return Constraint{Name: name, Op: op, Version: version}, nil
}
// Generic style: "pkg>=1.2" or "pkg >= 1.2" or plain "pkg".
name, op, version, err := splitNameAndConstraint(raw)
if err != nil {
return Constraint{}, err
}
return Constraint{Name: name, Op: op, Version: version}, nil
}
func splitNameAndConstraint(raw string) (string, VersionOp, string, error) {
name := strings.TrimSpace(raw)
op := OpAny
version := ""
idx, opFound := findOp(raw)
if opFound {
name = strings.TrimSpace(raw[:idx])
right := strings.TrimSpace(raw[idx:])
var err error
op, version, err = splitVersionConstraint(right)
if err != nil {
return "", OpAny, "", err
}
}
if !isValidDepName(name) {
return "", OpAny, "", fmt.Errorf("invalid package name %q", name)
}
return name, op, version, nil
}
func findOp(raw string) (int, bool) {
for i, r := range raw {
switch r {
case '<', '>', '=':
return i, true
}
}
return 0, false
}
func splitVersionConstraint(raw string) (VersionOp, string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return OpAny, "", fmt.Errorf("empty version constraint")
}
opToken := ""
version := ""
switch {
case strings.HasPrefix(raw, ">="):
opToken = ">="
version = strings.TrimSpace(raw[2:])
case strings.HasPrefix(raw, "<="):
opToken = "<="
version = strings.TrimSpace(raw[2:])
case strings.HasPrefix(raw, "="):
opToken = "="
version = strings.TrimSpace(raw[1:])
case strings.HasPrefix(raw, ">"):
opToken = ">"
version = strings.TrimSpace(raw[1:])
case strings.HasPrefix(raw, "<"):
opToken = "<"
version = strings.TrimSpace(raw[1:])
default:
parts := strings.Fields(raw)
if len(parts) == 2 {
opToken = parts[0]
version = parts[1]
}
}
if opToken == "" || version == "" {
return OpAny, "", fmt.Errorf("invalid version constraint %q", raw)
}
if strings.HasPrefix(version, "<") || strings.HasPrefix(version, ">") || strings.HasPrefix(version, "=") {
return OpAny, "", fmt.Errorf("invalid version %q", version)
}
if strings.ContainsAny(version, "()|") {
return OpAny, "", fmt.Errorf("invalid version %q", version)
}
op, err := parseVersionOp(opToken)
if err != nil {
return OpAny, "", err
}
return op, version, nil
}
func parseVersionOp(raw string) (VersionOp, error) {
switch strings.TrimSpace(raw) {
case "=":
return OpEqual, nil
case ">":
return OpGreater, nil
case ">=":
return OpGreaterOrEqual, nil
case "<":
return OpLess, nil
case "<=":
return OpLessOrEqual, nil
default:
return OpAny, fmt.Errorf("unsupported operator %q", raw)
}
}
func isValidDepName(name string) bool {
name = strings.TrimSpace(name)
if name == "" {
return false
}
for i, r := range name {
if i == 0 {
if !unicode.IsDigit(r) && !unicode.IsLetter(r) {
return false
}
continue
}
if unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
switch r {
case '+', '.', '-', '_':
default:
return false
}
}
return true
}
// MatchesVersion checks whether package version satisfies this constraint.
func (c Constraint) MatchesVersion(installedVersion string) bool {
switch c.Op {
case OpAny:
return true
case OpEqual:
return CompareVersions(installedVersion, c.Version) == 0
case OpGreater:
return CompareVersions(installedVersion, c.Version) > 0
case OpGreaterOrEqual:
return CompareVersions(installedVersion, c.Version) >= 0
case OpLess:
return CompareVersions(installedVersion, c.Version) < 0
case OpLessOrEqual:
return CompareVersions(installedVersion, c.Version) <= 0
default:
return false
}
}
// CompareVersions compares package versions.
// Returns -1 if a < b, 0 if a == b, and 1 if a > b.
func CompareVersions(a, b string) int {
epochA, restA := splitEpoch(a)
epochB, restB := splitEpoch(b)
if epochA != epochB {
if epochA < epochB {
return -1
}
return 1
}
mainA, relA := splitRelease(restA)
mainB, relB := splitRelease(restB)
if c := compareVersionPart(mainA, mainB); c != 0 {
return c
}
return compareVersionPart(relA, relB)
}
func splitEpoch(raw string) (int64, string) {
raw = strings.TrimSpace(raw)
if idx := strings.IndexByte(raw, ':'); idx > 0 {
epochRaw := strings.TrimSpace(raw[:idx])
if epoch, err := strconv.ParseInt(epochRaw, 10, 64); err == nil {
return epoch, strings.TrimSpace(raw[idx+1:])
}
}
return 0, raw
}
func splitRelease(raw string) (string, string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "0", "0"
}
if idx := strings.LastIndex(raw, "-"); idx > 0 && idx < len(raw)-1 {
return raw[:idx], raw[idx+1:]
}
return raw, "0"
}
func compareVersionPart(a, b string) int {
a = strings.TrimSpace(a)
b = strings.TrimSpace(b)
i, j := 0, 0
for i < len(a) || j < len(b) {
// Tilde sorts before everything, including end of string.
if i < len(a) && a[i] == '~' || j < len(b) && b[j] == '~' {
switch {
case i < len(a) && a[i] == '~' && j < len(b) && b[j] == '~':
i++
j++
continue
case i < len(a) && a[i] == '~':
return -1
default:
return 1
}
}
for i < len(a) && isVersionSeparator(a[i]) {
i++
}
for j < len(b) && isVersionSeparator(b[j]) {
j++
}
if i >= len(a) && j >= len(b) {
return 0
}
if i >= len(a) {
return -1
}
if j >= len(b) {
return 1
}
aNum := isDigit(a[i])
bNum := isDigit(b[j])
segA, nextI := readVersionSegment(a, i, aNum)
segB, nextJ := readVersionSegment(b, j, bNum)
i, j = nextI, nextJ
var c int
switch {
case aNum && bNum:
c = compareNumericSegment(segA, segB)
case aNum && !bNum:
c = 1
case !aNum && bNum:
c = -1
default:
c = strings.Compare(strings.ToLower(segA), strings.ToLower(segB))
}
if c < 0 {
return -1
}
if c > 0 {
return 1
}
}
return 0
}
func isVersionSeparator(ch byte) bool {
if ch == '~' {
return false
}
return !isDigit(ch) && !isLetter(ch)
}
func readVersionSegment(s string, start int, numeric bool) (string, int) {
i := start
for i < len(s) {
ch := s[i]
if numeric {
if !isDigit(ch) {
break
}
} else {
if !isLetter(ch) {
break
}
}
i++
}
return s[start:i], i
}
func compareNumericSegment(a, b string) int {
a = strings.TrimLeft(a, "0")
b = strings.TrimLeft(b, "0")
if a == "" {
a = "0"
}
if b == "" {
b = "0"
}
if len(a) < len(b) {
return -1
}
if len(a) > len(b) {
return 1
}
return strings.Compare(a, b)
}
func isDigit(ch byte) bool {
return ch >= '0' && ch <= '9'
}
func isLetter(ch byte) bool {
return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z'
}

134
pkg/deps/deps_test.go Normal file
View file

@ -0,0 +1,134 @@
package deps
import (
"reflect"
"testing"
)
func TestParseRequirement(t *testing.T) {
t.Parallel()
req, err := ParseRequirement("lua5.1 (>= 5.1.5) | luajit")
if err != nil {
t.Fatalf("ParseRequirement() error = %v", err)
}
if len(req.Alternatives) != 2 {
t.Fatalf("expected 2 alternatives, got %d", len(req.Alternatives))
}
first := req.Alternatives[0]
if first.Name != "lua5.1" || first.Op != OpGreaterOrEqual || first.Version != "5.1.5" {
t.Fatalf("unexpected first alt: %#v", first)
}
second := req.Alternatives[1]
if second.Name != "luajit" || second.Op != OpAny {
t.Fatalf("unexpected second alt: %#v", second)
}
}
func TestParseRequirementsInvalid(t *testing.T) {
t.Parallel()
invalid := []string{
"",
" ",
"bad dep",
"foo (>> 1)",
"foo >=",
"foo |",
"| foo",
}
for _, raw := range invalid {
if _, err := ParseRequirement(raw); err == nil {
t.Fatalf("expected parse error for %q", raw)
}
}
}
func TestCompareVersions(t *testing.T) {
t.Parallel()
cases := []struct {
a, b string
want int
}{
{"1.0", "1.0", 0},
{"1.0", "1.0.1", -1},
{"2.0", "1.9", 1},
{"1.02", "1.2", 0},
{"1.0~rc1", "1.0", -1},
{"1:1.0-1", "1.9.9", 1},
{"2:1.0-1", "1:9.0-9", 1},
{"1.0-2", "1.0-10", -1},
{"1.0+dfsg1", "1.0+dfsg2", -1},
{"1.0a", "1.0", 1},
}
for _, tc := range cases {
got := CompareVersions(tc.a, tc.b)
if sign(got) != sign(tc.want) {
t.Fatalf("CompareVersions(%q, %q): want sign %d, got %d", tc.a, tc.b, sign(tc.want), sign(got))
}
}
}
func TestConstraintMatchesVersion(t *testing.T) {
t.Parallel()
cases := []struct {
rawDep string
version string
want bool
}{
{"foo", "1.0", true},
{"foo=1.0", "1.0", true},
{"foo=1.0", "1.1", false},
{"foo>=1.0", "1.2", true},
{"foo>=1.0", "0.9", false},
{"foo<2.0", "1.9", true},
{"foo<2.0", "2.0", false},
{"foo (<= 2.1)", "2.1", true},
}
for _, tc := range cases {
req, err := ParseRequirement(tc.rawDep)
if err != nil {
t.Fatalf("ParseRequirement(%q) error = %v", tc.rawDep, err)
}
got := req.Alternatives[0].MatchesVersion(tc.version)
if got != tc.want {
t.Fatalf("MatchesVersion(%q, %q): want %v, got %v", tc.rawDep, tc.version, tc.want, got)
}
}
}
func TestParseRequirements(t *testing.T) {
t.Parallel()
input := []string{"foo", "bar>=1.2", "baz | qux"}
got, err := ParseRequirements(input)
if err != nil {
t.Fatalf("ParseRequirements() error = %v", err)
}
if len(got) != 3 {
t.Fatalf("expected 3 requirements, got %d", len(got))
}
names := []string{got[0].Alternatives[0].Name, got[1].Alternatives[0].Name, got[2].Alternatives[0].Name}
if want := []string{"foo", "bar", "baz"}; !reflect.DeepEqual(names, want) {
t.Fatalf("unexpected parsed names:\nwant: %#v\ngot: %#v", want, names)
}
}
func sign(v int) int {
switch {
case v < 0:
return -1
case v > 0:
return 1
default:
return 0
}
}

View file

@ -1,6 +1,7 @@
package installer
import (
"container/heap"
"fmt"
"io"
"os"
@ -9,6 +10,7 @@ import (
"strings"
"syscall"
"zsvo/pkg/deps"
"zsvo/pkg/packager"
"zsvo/pkg/types"
)
@ -153,15 +155,12 @@ func (i *Installer) installManyNoLock(packagePaths []string) error {
defer os.RemoveAll(txDir)
applied := make([]installTransactionState, 0, len(candidates))
remaining := append([]installCandidate(nil), candidates...)
for len(remaining) > 0 {
progress := false
for idx, candidate := range remaining {
if !depsSatisfied(candidate.info.Dependencies, installedInfos, candidate.info.Name) {
continue
ordered, err := solveInstallOrder(candidates, installedInfos)
if err != nil {
return err
}
for _, candidate := range ordered {
state, err := i.installPackageTxNoLock(candidate.path, txDir, installedInfos)
if err != nil {
if rbErr := i.rollbackInstallTransactionNoLock(applied); rbErr != nil {
@ -172,29 +171,6 @@ func (i *Installer) installManyNoLock(packagePaths []string) error {
applied = append(applied, *state)
installedInfos[candidate.info.Name] = candidate.info
remaining = append(remaining[:idx], remaining[idx+1:]...)
progress = true
break
}
if progress {
continue
}
block := remaining[0]
missing := missingDeps(block.info.Dependencies, installedInfos, block.info.Name)
if len(missing) == 0 {
missing = []string{"unknown ordering issue"}
}
if rbErr := i.rollbackInstallTransactionNoLock(applied); rbErr != nil {
return fmt.Errorf(
"cannot resolve install order for %s: missing deps %s (rollback error: %v)",
block.info.Name,
strings.Join(missing, ", "),
rbErr,
)
}
return fmt.Errorf("cannot resolve install order for %s: missing deps %s", block.info.Name, strings.Join(missing, ", "))
}
return nil
@ -844,47 +820,50 @@ func removePathIfExists(path string) error {
return nil
}
func validateSimpleDependencies(deps []string) error {
for _, dep := range deps {
if depNameFromConstraint(dep) == "" {
return fmt.Errorf("invalid dependency: %q", dep)
}
func validateSimpleDependencies(rawDeps []string) error {
if _, err := deps.ParseRequirements(rawDeps); err != nil {
return err
}
return nil
}
func depsSatisfied(deps []string, installedInfos map[string]*types.PkgInfo, selfName string) bool {
for _, dep := range deps {
depName := depNameFromConstraint(dep)
if depName == "" {
func depsSatisfied(rawDeps []string, installedInfos map[string]*types.PkgInfo, selfName string) bool {
reqs, err := deps.ParseRequirements(rawDeps)
if err != nil {
return false
}
if depName == selfName {
continue
}
if _, ok := installedInfos[depName]; !ok {
available := packageVersions(installedInfos)
for _, req := range reqs {
if _, ok := resolveRequirementProvider(req, available, nil, selfName); !ok {
return false
}
}
return true
}
func missingDeps(deps []string, installedInfos map[string]*types.PkgInfo, selfName string) []string {
func missingDeps(rawDeps []string, installedInfos map[string]*types.PkgInfo, selfName string) []string {
reqs, err := deps.ParseRequirements(rawDeps)
if err != nil {
return []string{err.Error()}
}
missing := make([]string, 0)
seen := make(map[string]struct{})
for _, dep := range deps {
depName := depNameFromConstraint(dep)
if depName == "" || depName == selfName {
available := packageVersions(installedInfos)
for _, req := range reqs {
if _, ok := resolveRequirementProvider(req, available, nil, selfName); ok {
continue
}
if _, ok := installedInfos[depName]; ok {
label := req.Raw
if label == "" {
label = formatRequirement(req)
}
if _, exists := seen[label]; exists {
continue
}
if _, exists := seen[depName]; exists {
continue
}
seen[depName] = struct{}{}
missing = append(missing, depName)
seen[label] = struct{}{}
missing = append(missing, label)
}
sort.Strings(missing)
return missing
@ -901,27 +880,6 @@ func checkDependenciesAgainstInstalled(deps []string, installedInfos map[string]
return fmt.Errorf("dependency not installed: %s", strings.Join(missing, ", "))
}
func depNameFromConstraint(dep string) string {
dep = strings.TrimSpace(dep)
if dep == "" {
return ""
}
if idx := strings.Index(dep, "|"); idx >= 0 {
dep = strings.TrimSpace(dep[:idx])
}
for idx, r := range dep {
switch r {
case '<', '>', '=', ' ', '\t', '\n', '\r':
dep = strings.TrimSpace(dep[:idx])
return dep
}
}
return dep
}
func normalizePackageNames(packageNames []string) ([]string, error) {
if len(packageNames) == 0 {
return nil, fmt.Errorf("no packages provided")
@ -961,17 +919,15 @@ func brokenPackagesAfterRemoval(infos map[string]*types.PkgInfo, removeSet map[s
sort.Strings(names)
broken := make([]string, 0)
remainingVersions := packageVersions(remaining)
for _, pkgName := range names {
pkgInfo := remaining[pkgName]
for _, dep := range pkgInfo.Dependencies {
depName := depNameFromConstraint(dep)
if depName == "" {
return nil, fmt.Errorf("invalid dependency in package %s: %q", pkgName, dep)
reqs, err := deps.ParseRequirements(pkgInfo.Dependencies)
if err != nil {
return nil, fmt.Errorf("invalid dependency in package %s: %w", pkgName, err)
}
if depName == pkgName {
continue
}
if _, ok := remaining[depName]; !ok {
for _, req := range reqs {
if _, ok := resolveRequirementProvider(req, remainingVersions, nil, pkgName); !ok {
broken = append(broken, pkgName)
break
}
@ -983,15 +939,18 @@ func brokenPackagesAfterRemoval(infos map[string]*types.PkgInfo, removeSet map[s
func listOrphansFromInfos(infos map[string]*types.PkgInfo) []string {
required := make(map[string]struct{})
versions := packageVersions(infos)
for owner, info := range infos {
for _, dep := range info.Dependencies {
depName := depNameFromConstraint(dep)
if depName == "" || depName == owner {
reqs, err := deps.ParseRequirements(info.Dependencies)
if err != nil {
continue
}
if _, ok := infos[depName]; ok {
required[depName] = struct{}{}
for _, req := range reqs {
provider, ok := resolveRequirementProvider(req, versions, nil, owner)
if !ok || provider == owner {
continue
}
required[provider] = struct{}{}
}
}
@ -1005,6 +964,191 @@ func listOrphansFromInfos(infos map[string]*types.PkgInfo) []string {
return orphans
}
func solveInstallOrder(candidates []installCandidate, installedInfos map[string]*types.PkgInfo) ([]installCandidate, error) {
if len(candidates) == 0 {
return nil, nil
}
candidateByName := make(map[string]installCandidate, len(candidates))
for _, candidate := range candidates {
if candidate.info == nil {
return nil, fmt.Errorf("invalid install candidate: nil metadata")
}
if prev, exists := candidateByName[candidate.info.Name]; exists {
return nil, fmt.Errorf("duplicate package %s in transaction: %s and %s", candidate.info.Name, prev.path, candidate.path)
}
candidateByName[candidate.info.Name] = candidate
}
available := packageVersions(installedInfos)
for _, candidate := range candidates {
available[candidate.info.Name] = candidate.info.Version
}
indegree := make(map[string]int, len(candidates))
dependents := make(map[string][]string, len(candidates))
for _, candidate := range candidates {
indegree[candidate.info.Name] = 0
}
for _, candidate := range candidates {
reqs, err := deps.ParseRequirements(candidate.info.Dependencies)
if err != nil {
return nil, fmt.Errorf("invalid dependencies in %s: %w", candidate.info.Name, err)
}
edgeSet := make(map[string]struct{})
for _, req := range reqs {
provider, ok := resolveRequirementProvider(req, available, candidateByName, candidate.info.Name)
if !ok {
return nil, fmt.Errorf("cannot resolve dependencies for %s: missing %s", candidate.info.Name, req.Raw)
}
if provider == candidate.info.Name {
continue
}
if _, inTx := candidateByName[provider]; !inTx {
continue
}
if _, seen := edgeSet[provider]; seen {
continue
}
edgeSet[provider] = struct{}{}
dependents[provider] = append(dependents[provider], candidate.info.Name)
indegree[candidate.info.Name]++
}
}
ready := &stringMinHeap{}
heap.Init(ready)
for name, deg := range indegree {
if deg == 0 {
heap.Push(ready, name)
}
}
ordered := make([]installCandidate, 0, len(candidates))
for ready.Len() > 0 {
name := heap.Pop(ready).(string)
ordered = append(ordered, candidateByName[name])
next := dependents[name]
sort.Strings(next)
for _, depName := range next {
indegree[depName]--
if indegree[depName] == 0 {
heap.Push(ready, depName)
}
}
}
if len(ordered) != len(candidates) {
stuck := make([]string, 0)
for name, deg := range indegree {
if deg > 0 {
stuck = append(stuck, name)
}
}
sort.Strings(stuck)
return nil, fmt.Errorf("cannot resolve install order: dependency cycle among %s", strings.Join(stuck, ", "))
}
return ordered, nil
}
func packageVersions(infos map[string]*types.PkgInfo) map[string]string {
out := make(map[string]string, len(infos))
for name, info := range infos {
if info == nil {
continue
}
out[name] = info.Version
}
return out
}
func resolveRequirementProvider(
req deps.Requirement,
availableVersions map[string]string,
candidateByName map[string]installCandidate,
selfName string,
) (string, bool) {
for _, alt := range req.Alternatives {
version, ok := availableVersions[alt.Name]
if !ok {
continue
}
if alt.Name == selfName && candidateByName != nil {
if selfCandidate, exists := candidateByName[selfName]; exists {
version = selfCandidate.info.Version
}
}
if !alt.MatchesVersion(version) {
continue
}
// If a package with this name is in current transaction, it replaces installed one.
if candidateByName != nil {
if candidate, exists := candidateByName[alt.Name]; exists {
if alt.MatchesVersion(candidate.info.Version) {
return candidate.info.Name, true
}
continue
}
}
return alt.Name, true
}
return "", false
}
func formatRequirement(req deps.Requirement) string {
if strings.TrimSpace(req.Raw) != "" {
return strings.TrimSpace(req.Raw)
}
parts := make([]string, 0, len(req.Alternatives))
for _, alt := range req.Alternatives {
if alt.Op == deps.OpAny {
parts = append(parts, alt.Name)
continue
}
parts = append(parts, fmt.Sprintf("%s%s%s", alt.Name, formatOp(alt.Op), alt.Version))
}
return strings.Join(parts, " | ")
}
func formatOp(op deps.VersionOp) string {
switch op {
case deps.OpEqual:
return "="
case deps.OpGreater:
return ">"
case deps.OpGreaterOrEqual:
return ">="
case deps.OpLess:
return "<"
case deps.OpLessOrEqual:
return "<="
default:
return ""
}
}
type stringMinHeap []string
func (h stringMinHeap) Len() int { return len(h) }
func (h stringMinHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h stringMinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *stringMinHeap) Push(x interface{}) {
*h = append(*h, x.(string))
}
func (h *stringMinHeap) Pop() interface{} {
old := *h
last := len(old) - 1
item := old[last]
*h = old[:last]
return item
}
func sanitizePackagePath(path string) (string, error) {
if path == "" {
return "", fmt.Errorf("file path cannot be empty")

View file

@ -142,6 +142,132 @@ func TestInstallManyRollsBackOnFailure(t *testing.T) {
}
}
func TestInstallManyRespectsVersionConstraints(t *testing.T) {
t.Parallel()
workDir := t.TempDir()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
libPkg := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "libfoo",
Version: "2.1.0",
Build: []string{"true"},
Install: []string{"true"},
}, map[string][]byte{"usr/lib/libfoo.so": []byte("lib2\n")})
appPkg := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "app",
Version: "1.0.0",
Build: []string{"true"},
Install: []string{"true"},
Deps: []string{"libfoo>=2.0.0"},
}, map[string][]byte{"usr/bin/app": []byte("app\n")})
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 TestInstallManyFailsOnUnsatisfiedVersionConstraint(t *testing.T) {
t.Parallel()
workDir := t.TempDir()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
libOld := 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("lib1\n")})
if err := ins.Install(libOld); err != nil {
t.Fatalf("Install(libOld) error = %v", err)
}
appPkg := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "app",
Version: "1.0.0",
Build: []string{"true"},
Install: []string{"true"},
Deps: []string{"libfoo>=2.0.0"},
}, map[string][]byte{"usr/bin/app": []byte("app\n")})
err := ins.Install(appPkg)
if err == nil {
t.Fatalf("expected install error for unsatisfied version constraint")
}
if !strings.Contains(err.Error(), "libfoo>=2.0.0") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestInstallManySupportsAlternativeDependencies(t *testing.T) {
t.Parallel()
workDir := t.TempDir()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
libBarPkg := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "libbar",
Version: "1.5.0",
Build: []string{"true"},
Install: []string{"true"},
}, map[string][]byte{"usr/lib/libbar.so": []byte("bar\n")})
appPkg := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "app",
Version: "1.0.0",
Build: []string{"true"},
Install: []string{"true"},
Deps: []string{"libfoo | libbar>=1.2.0"},
}, map[string][]byte{"usr/bin/app": []byte("app\n")})
if err := ins.InstallMany([]string{appPkg, libBarPkg}); err != nil {
t.Fatalf("InstallMany() error = %v", err)
}
if !ins.IsInstalled("libbar") || !ins.IsInstalled("app") {
t.Fatalf("expected libbar and app to be installed")
}
}
func TestInstallManyDetectsDependencyCycle(t *testing.T) {
t.Parallel()
workDir := t.TempDir()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
pkgA := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "a",
Version: "1.0.0",
Build: []string{"true"},
Install: []string{"true"},
Deps: []string{"b>=1.0"},
}, map[string][]byte{"usr/bin/a": []byte("a\n")})
pkgB := buildTestPackage(t, workDir, &recipe.Recipe{
Name: "b",
Version: "1.0.0",
Build: []string{"true"},
Install: []string{"true"},
Deps: []string{"a>=1.0"},
}, map[string][]byte{"usr/bin/b": []byte("b\n")})
err := ins.InstallMany([]string{pkgA, pkgB})
if err == nil {
t.Fatalf("expected cycle error")
}
if !strings.Contains(err.Error(), "dependency cycle") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestInstallRespectsRootFlag(t *testing.T) {
t.Parallel()
@ -219,6 +345,43 @@ func TestRemoveManyCascade(t *testing.T) {
}
}
func TestRemoveManyAllowsAlternativeDependencyProvider(t *testing.T) {
t.Parallel()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
libFoo := &types.PkgInfo{Name: "libfoo", Version: "1.0.0", Files: []string{"usr/lib/libfoo.so"}}
libBar := &types.PkgInfo{Name: "libbar", Version: "1.0.0", Files: []string{"usr/lib/libbar.so"}}
app := &types.PkgInfo{
Name: "app",
Version: "1.0.0",
Dependencies: []string{"libfoo | libbar"},
Files: []string{"usr/bin/app"},
}
if err := ins.registerPackage(libFoo); err != nil {
t.Fatalf("registerPackage(libFoo) error = %v", err)
}
if err := ins.registerPackage(libBar); err != nil {
t.Fatalf("registerPackage(libBar) error = %v", err)
}
if err := ins.registerPackage(app); err != nil {
t.Fatalf("registerPackage(app) error = %v", err)
}
removed, err := ins.RemoveMany([]string{"libfoo"}, RemoveOptions{})
if err != nil {
t.Fatalf("RemoveMany() error = %v", err)
}
if len(removed) != 1 || removed[0] != "libfoo" {
t.Fatalf("unexpected removed list: %#v", removed)
}
if !ins.IsInstalled("app") {
t.Fatalf("app should remain installed because libbar satisfies dependency")
}
}
func TestListOrphans(t *testing.T) {
t.Parallel()
@ -259,3 +422,44 @@ func TestListOrphans(t *testing.T) {
t.Fatalf("did not expect libfoo to be orphan")
}
}
func TestListOrphansAlternativeDependencyMarksChosenProvider(t *testing.T) {
t.Parallel()
rootDir := t.TempDir()
ins := NewInstaller(rootDir)
libFoo := &types.PkgInfo{Name: "libfoo", Version: "1.0.0"}
libBar := &types.PkgInfo{Name: "libbar", Version: "1.0.0"}
app := &types.PkgInfo{Name: "app", Version: "1.0.0", Dependencies: []string{"libfoo | libbar"}}
if err := ins.registerPackage(libFoo); err != nil {
t.Fatalf("registerPackage(libFoo) error = %v", err)
}
if err := ins.registerPackage(libBar); err != nil {
t.Fatalf("registerPackage(libBar) error = %v", err)
}
if err := ins.registerPackage(app); err != nil {
t.Fatalf("registerPackage(app) 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["libbar"]; !ok {
t.Fatalf("expected libbar to be orphan when libfoo is first satisfiable provider")
}
if _, ok := set["libfoo"]; ok {
t.Fatalf("did not expect libfoo to be orphan")
}
}

View file

@ -7,6 +7,8 @@ import (
"os"
"path/filepath"
"strings"
"zsvo/pkg/deps"
)
// Recipe represents a package recipe.
@ -200,8 +202,8 @@ func ParseRecipeFromReader(r io.Reader) (*Recipe, error) {
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)
if _, err := deps.ParseRequirement(dep); err != nil {
return nil, fmt.Errorf("invalid recipe dependency %q: %w", dep, err)
}
}

View file

@ -42,7 +42,7 @@ deps:
}
}
func TestParseRecipeRejectsComplexDependencySyntax(t *testing.T) {
func TestParseRecipeAcceptsComplexDependencySyntax(t *testing.T) {
t.Parallel()
input := `
@ -57,11 +57,15 @@ install:
- make DESTDIR={{pkgdir}} install
deps:
- glibc>=2.39
- liblua5.1-0 | libluajit-5.1-2
`
_, err := ParseRecipeFromReader(strings.NewReader(input))
if err == nil {
t.Fatalf("expected parse error for unsupported dependency syntax")
r, err := ParseRecipeFromReader(strings.NewReader(input))
if err != nil {
t.Fatalf("expected complex dependencies to parse, got error: %v", err)
}
if len(r.Deps) != 2 {
t.Fatalf("expected 2 deps, got %d", len(r.Deps))
}
}

BIN
zsvo

Binary file not shown.