diff --git a/.gitignore b/.gitignore index 08acfef..4246fe7 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ coverage.txt # Dependency directories vendor/ node_modules/ +yay/ # Environment files .env @@ -45,4 +46,4 @@ node_modules/ # Backup files *.bak *.backup -*.orig \ No newline at end of file +*.orig diff --git a/README.md b/README.md index 37144f0..e57ebc2 100644 --- a/README.md +++ b/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: -``` -zsvo/ -├── go.mod # Go module definition -├── go.sum # Go dependencies -├── main.go # CLI entry point -├── cmd/ # CLI commands -│ ├── build.go # pkg build command -│ ├── install.go # pkg install command -│ ├── remove.go # pkg remove command -│ ├── list.go # pkg list command -│ └── info.go # pkg info command -├── pkg/ # Core packages -│ ├── recipe/ # Recipe parsing and validation -│ │ └── recipe.go -│ ├── fetcher/ # Source downloading and extraction -│ │ └── fetcher.go -│ ├── builder/ # Package building -│ │ └── builder.go -│ ├── packager/ # Package creation -│ │ └── packager.go -│ └── installer/ # Package installation/removal -│ └── installer.go -└── recipes/ # Example recipes - └── zlib.toml -``` +`recipe -> download source -> extract -> build -> install into DESTDIR -> create package -> install package` -## Modules - -### Recipe Module (`pkg/recipe/`) -- Parses TOML recipe files -- Validates recipe structure -- Provides package naming utilities -- Defines `Recipe`, `Source`, `Build`, and `PkgInfo` structs - -### Fetcher Module (`pkg/fetcher/`) -- Downloads sources with checksum verification -- Extracts various archive formats -- Handles patch application -- Implements caching for downloaded sources -- Supports concurrent downloads - -### Builder Module (`pkg/builder/`) -- Manages build process from recipe -- Sets up build environment -- Executes build commands -- Handles source directory management -- Provides build information and file listing - -### Packager Module (`pkg/packager/`) -- Creates compressed package archives -- Generates package metadata (.pkginfo) -- Extracts packages for installation -- Verifies package integrity -- Lists package contents - -### Installer Module (`pkg/installer/`) -- Installs packages to root filesystem -- Manages package database -- Handles dependency checking -- Removes packages and cleans up -- Provides package information queries - -## Usage - -### Building a Package +## Commands ```bash -# Build from recipe -pkg build recipes/zlib.toml +# Build package from recipe +zsvo build recipes/zlib.yaml -# Build with custom work directory -pkg build -w /tmp/build zlib.toml -``` - -### Installing a Package - -```bash -# Install package -pkg install /path/to/package.pkg.tar.zst +# Install package built by zsvo +zsvo install /path/to/name-version.pkg.tar.zst # Install to custom root -pkg install -r /mnt/root package.pkg.tar.zst +zsvo install --root /mnt/root /path/to/name-version.pkg.tar.zst + +# Remove package(s) +zsvo remove bash +zsvo remove -c libfoo # cascade +zsvo remove -n bash # dry-run + +# List installed / orphan packages +zsvo list +zsvo list --orphans + +# Show package metadata +zsvo info bash ``` -### Managing Packages +## Recipe Format (YAML) -```bash -# List installed packages -pkg list +```yaml +name: bash +version: "5.2" -# Show package information -pkg info zlib +description: GNU Bourne Again SHell -# Remove package -pkg remove zlib +source: + url: https://ftp.gnu.org/gnu/bash/bash-5.2.tar.gz + sha256: examplehash + +build: + - ./configure --prefix=/usr + - make -j$(nproc) + +install: + - make DESTDIR={{pkgdir}} install + +deps: + - glibc + - readline ``` -## Recipe Format +Debian upstream source mode: -Recipes are written in TOML format: +```yaml +name: bash +version: "5.2" -```toml -name = "package-name" -version = "1.0.0" -description = "Package description" - -[source] -url = "https://example.com/package-1.0.0.tar.gz" -sha256 = "sha256-hash-of-source" -patches = ["patch1.diff", "patch2.diff"] - -[build] -commands = [ - "./configure --prefix=/usr", - "make -j$(nproc)", - "make DESTDIR={{pkgdir}} install" -] -env = [ - "CFLAGS=-O2" -] - -[dependencies] -# List of package dependencies +source: + debian_dsc: https://deb.debian.org/debian/pool/main/b/bash/bash_5.2.37-2.dsc + # optional: sha256 of the .dsc file itself + # sha256: ``` -## Package Format +Notes: +- `deps` are simple package names (no version solver yet). +- `{{pkgdir}}` (and `${pkgdir}`) points to staging DESTDIR. +- If `source.debian_dsc` (or `.dsc` URL) is used, zsvo downloads only `*.orig*.tar.*` archives from that source package and ignores Debian patch archives (`debian.tar.*` / `diff.gz`). +- Package metadata is stored in `.zsvo.yml` (not `.PKGINFO`). -Packages are created as `name-version.pkg.tar.zst` archives containing: -- Installed files -- `.pkginfo` metadata file +## Architecture -## Package Database - -Installed packages are tracked in `/var/lib/pkgdb//` with: -- `.pkginfo` file containing package metadata -- File lists and installation information - -## Dependencies - -- `github.com/BurntSushi/toml` - TOML parsing -- `github.com/mholt/archiver/v4` - Archive handling -- `github.com/spf13/cobra` - CLI framework -- `github.com/spf13/viper` - Configuration management -- `golang.org/x/sync` - Concurrent operations - -## Philosophy - -This package manager follows the simplicity philosophy of Arch Linux tools: -- Minimal dependencies -- Clear separation of concerns -- Production-ready code -- Source-based package management -- Simple, understandable implementation \ No newline at end of file +- `pkg/recipe` — YAML recipe parser. +- `pkg/fetcher` — download + checksum + extract + patch apply. +- `pkg/builder` — build/install pipeline in isolated workdir. +- `pkg/packager` — create/read `name-version.pkg.tar.zst` from staging. +- `pkg/installer` — install/remove packages into `--root` with file safety checks and rollback. diff --git a/cmd/build.go b/cmd/build.go index 6f972de..95c7ddd 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "path/filepath" "github.com/spf13/cobra" "zsvo/pkg/builder" @@ -17,7 +18,7 @@ var BuildCmd = &cobra.Command{ recipePath := args[0] // Parse recipe - recipe, err := recipe.ParseRecipe(recipePath) + rcp, err := recipe.ParseRecipe(recipePath) if err != nil { return fmt.Errorf("failed to parse recipe: %w", err) } @@ -31,12 +32,13 @@ var BuildCmd = &cobra.Command{ b := builder.NewBuilder(workDir) // Build package - fmt.Printf("Building package %s...\n", recipe.GetPackageName()) - if err := b.Build(recipe); err != nil { + fmt.Printf("Building package %s...\n", rcp.GetPackageName()) + if err := b.Build(rcp); err != nil { return fmt.Errorf("failed to build package: %w", err) } - fmt.Printf("Package %s built successfully\n", recipe.GetPackageName()) + fmt.Printf("Package %s built successfully\n", rcp.GetPackageName()) + fmt.Printf("Package file: %s\n", filepath.Join(rcp.GetPackageDir(workDir), rcp.GetPackageFileName())) return nil }, } diff --git a/cmd/install.go b/cmd/install.go index d0230eb..693bdaa 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -8,13 +8,11 @@ import ( ) var InstallCmd = &cobra.Command{ - Use: "install ", - Short: "Install a package", - Long: `Install a package from a package file`, - Args: cobra.ExactArgs(1), + Use: "install [package...]", + Short: "Install package(s)", + Long: `Install one or more packages from package files`, + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - packagePath := args[0] - // Create installer rootDir, _ := cmd.Flags().GetString("root") if rootDir == "" { @@ -23,13 +21,17 @@ var InstallCmd = &cobra.Command{ i := installer.NewInstaller(rootDir) - // Install package - fmt.Printf("Installing package from %s...\n", packagePath) - if err := i.Install(packagePath); err != nil { - return fmt.Errorf("failed to install package: %w", err) + // Install packages + if len(args) == 1 { + fmt.Printf("Installing package from %s...\n", args[0]) + } else { + fmt.Printf("Installing %d packages...\n", len(args)) + } + if err := i.InstallMany(args); err != nil { + return fmt.Errorf("failed to install packages: %w", err) } - fmt.Printf("Package installed successfully\n") + fmt.Printf("Package installation completed successfully\n") return nil }, } diff --git a/cmd/list.go b/cmd/list.go index cecdf58..266e7a6 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -19,19 +19,35 @@ var ListCmd = &cobra.Command{ } i := installer.NewInstaller(rootDir) + orphansOnly, _ := cmd.Flags().GetBool("orphans") - // List installed packages - packages, err := i.ListInstalled() + var ( + packages []string + err error + ) + if orphansOnly { + packages, err = i.ListOrphans() + } else { + packages, err = i.ListInstalled() + } if err != nil { return fmt.Errorf("failed to list packages: %w", err) } if len(packages) == 0 { - fmt.Println("No packages installed") + if orphansOnly { + fmt.Println("No orphan packages") + } else { + fmt.Println("No packages installed") + } return nil } - fmt.Println("Installed packages:") + if orphansOnly { + fmt.Println("Orphan packages:") + } else { + fmt.Println("Installed packages:") + } for _, pkg := range packages { fmt.Printf(" %s\n", pkg) } @@ -41,4 +57,5 @@ var ListCmd = &cobra.Command{ func init() { ListCmd.Flags().StringP("root", "r", "/", "Root directory for installation") + ListCmd.Flags().BoolP("orphans", "o", false, "List only orphan packages") } diff --git a/cmd/remove.go b/cmd/remove.go index 9ea4714..fbe288a 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -8,13 +8,11 @@ import ( ) var RemoveCmd = &cobra.Command{ - Use: "remove ", - Short: "Remove a package", - Long: `Remove an installed package`, - Args: cobra.ExactArgs(1), + Use: "remove [package...]", + Short: "Remove package(s)", + Long: `Remove one or more installed packages`, + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - packageName := args[0] - // Create installer rootDir, _ := cmd.Flags().GetString("root") if rootDir == "" { @@ -22,18 +20,45 @@ var RemoveCmd = &cobra.Command{ } i := installer.NewInstaller(rootDir) - - // Remove package - fmt.Printf("Removing package %s...\n", packageName) - if err := i.Remove(packageName); err != nil { - return fmt.Errorf("failed to remove package: %w", err) + cascade, _ := cmd.Flags().GetBool("cascade") + dryRun, _ := cmd.Flags().GetBool("dry-run") + options := installer.RemoveOptions{ + Cascade: cascade, } - fmt.Printf("Package %s removed successfully\n", packageName) + if dryRun { + plan, err := i.PlanRemove(args, options) + if err != nil { + return fmt.Errorf("failed to calculate removal plan: %w", err) + } + + if len(plan) == 0 { + fmt.Println("Nothing to remove") + return nil + } + + fmt.Printf("Planned removal (%d package(s)):\n", len(plan)) + for _, pkgName := range plan { + fmt.Printf(" %s\n", pkgName) + } + return nil + } + + removed, err := i.RemoveMany(args, options) + if err != nil { + return fmt.Errorf("failed to remove packages: %w", err) + } + + fmt.Printf("Removed %d package(s):\n", len(removed)) + for _, pkgName := range removed { + fmt.Printf(" %s\n", pkgName) + } return nil }, } func init() { RemoveCmd.Flags().StringP("root", "r", "/", "Root directory for installation") + RemoveCmd.Flags().BoolP("cascade", "c", false, "Remove dependent packages that become broken") + RemoveCmd.Flags().BoolP("dry-run", "n", false, "Show packages that would be removed without applying changes") } diff --git a/cmd/upgrade.go b/cmd/upgrade.go new file mode 100644 index 0000000..36bf9e0 --- /dev/null +++ b/cmd/upgrade.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "zsvo/pkg/installer" +) + +var UpgradeCmd = &cobra.Command{ + Use: "upgrade [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") +} diff --git a/go.mod b/go.mod index afcab50..3919e8a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module zsvo go 1.23.0 require ( - github.com/BurntSushi/toml v1.3.2 github.com/mholt/archiver/v3 v3.5.1 github.com/spf13/cobra v1.8.1 golang.org/x/sync v0.12.0 diff --git a/go.sum b/go.sum index 1baf230..cf9d28a 100644 --- a/go.sum +++ b/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/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -38,4 +36,3 @@ golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index f4ecd6e..60a380c 100644 --- a/main.go +++ b/main.go @@ -9,14 +9,15 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "pkg", + Use: "zsvo", Short: "A simple source-based package manager", Long: `A minimal package manager for custom Linux distributions based on LFS Available commands: build Build a package from recipe - install Install a package from package file - remove Remove an installed package + install Install package(s) from package files + upgrade Upgrade package(s) from package files + remove Remove installed package(s) list List installed packages info Show package information help Show help for a command @@ -27,6 +28,7 @@ func init() { // Register all commands rootCmd.AddCommand(cmd.BuildCmd) rootCmd.AddCommand(cmd.InstallCmd) + rootCmd.AddCommand(cmd.UpgradeCmd) rootCmd.AddCommand(cmd.RemoveCmd) rootCmd.AddCommand(cmd.ListCmd) rootCmd.AddCommand(cmd.InfoCmd) diff --git a/pkg/builder/builder.go b/pkg/builder/builder.go index 7796dd0..6560320 100644 --- a/pkg/builder/builder.go +++ b/pkg/builder/builder.go @@ -8,8 +8,9 @@ import ( "runtime" "strings" - "zsvo/pkg/recipe" "zsvo/pkg/fetcher" + "zsvo/pkg/packager" + "zsvo/pkg/recipe" ) // Builder handles building packages from recipes @@ -26,6 +27,10 @@ func NewBuilder(workDir string) *Builder { // Build builds a package from a recipe func (b *Builder) Build(recipe *recipe.Recipe) error { + if recipe == nil { + return fmt.Errorf("recipe cannot be nil") + } + // Create working directories sourceDir := recipe.GetSourceDir(b.workDir) stagingDir := recipe.GetStagingDir(b.workDir) @@ -51,10 +56,16 @@ func (b *Builder) Build(recipe *recipe.Recipe) error { } // Package files - if err := b.packageFiles(recipe, sourceDir, stagingDir, packageDir); err != nil { + if err := b.packageFiles(recipe, sourceDir, stagingDir); err != nil { return fmt.Errorf("failed to package files: %w", err) } + // Create package archive + p := packager.NewPackager(b.workDir) + if err := p.Package(recipe); err != nil { + return fmt.Errorf("failed to create package archive: %w", err) + } + return nil } @@ -77,11 +88,25 @@ func (b *Builder) prepareDirectories(sourceDir, stagingDir, packageDir string) e // downloadAndExtract downloads and extracts source func (b *Builder) downloadAndExtract(recipe *recipe.Recipe) error { + sourceDir := recipe.GetSourceDir(b.workDir) + if err := os.RemoveAll(sourceDir); err != nil { + return fmt.Errorf("failed to clean source directory: %w", err) + } + if err := os.MkdirAll(sourceDir, 0755); err != nil { + return fmt.Errorf("failed to recreate source directory: %w", err) + } + f := fetcher.NewFetcher(filepath.Join(b.workDir, "cache")) + + sourceURL := recipe.Source.URL + if recipe.Source.DebianDSC != "" { + sourceURL = recipe.Source.DebianDSC + } + return f.DownloadAndExtract( - recipe.Source.URL, + sourceURL, recipe.Source.Sha256, - recipe.GetSourceDir(b.workDir), + sourceDir, ) } @@ -91,8 +116,20 @@ func (b *Builder) applyPatches(recipe *recipe.Recipe, sourceDir string) error { return nil } + patches := make([]string, 0, len(recipe.Source.Patches)) + for _, patchPath := range recipe.Source.Patches { + if patchPath == "" { + return fmt.Errorf("patch path cannot be empty") + } + + if !filepath.IsAbs(patchPath) && recipe.Dir != "" { + patchPath = filepath.Join(recipe.Dir, patchPath) + } + patches = append(patches, patchPath) + } + f := fetcher.NewFetcher(filepath.Join(b.workDir, "cache")) - return f.ApplyPatches(sourceDir, recipe.Source.Patches) + return f.ApplyPatches(sourceDir, patches) } // executeBuild executes build commands @@ -104,13 +141,13 @@ func (b *Builder) executeBuild(recipe *recipe.Recipe, sourceDir, stagingDir stri } // Set up environment - env := b.buildEnvironment(recipe, stagingDir) + env := b.buildEnvironment(stagingDir) // Execute build commands - for i, cmd := range recipe.Build.Commands { + for i, cmd := range recipe.Build { // Substitute variables in command - cmd = b.substituteVariables(cmd, recipe, sourceDir, stagingDir) - + cmd = b.substituteVariables(cmd, sourceDir, stagingDir) + if err := b.executeCommand(srcPath, cmd, env); err != nil { return fmt.Errorf("build command %d failed: %w", i+1, err) } @@ -144,7 +181,7 @@ func (b *Builder) findSourceDirectory(sourceDir string) (string, error) { } // buildEnvironment builds the environment for build commands -func (b *Builder) buildEnvironment(recipe *recipe.Recipe, stagingDir string) []string { +func (b *Builder) buildEnvironment(stagingDir string) []string { env := os.Environ() // Add standard build environment variables @@ -152,11 +189,6 @@ func (b *Builder) buildEnvironment(recipe *recipe.Recipe, stagingDir string) []s env = append(env, fmt.Sprintf("PREFIX=/usr")) env = append(env, fmt.Sprintf("PKGDIR=%s", stagingDir)) - // Add recipe-specific environment variables - for _, envVar := range recipe.Build.Env { - env = append(env, envVar) - } - // Add parallel build variable env = append(env, fmt.Sprintf("MAKEFLAGS=-j%d", runtime.NumCPU())) @@ -165,13 +197,11 @@ func (b *Builder) buildEnvironment(recipe *recipe.Recipe, stagingDir string) []s // executeCommand executes a single command func (b *Builder) executeCommand(workDir, command string, env []string) error { - // Parse command - args := strings.Fields(command) - if len(args) == 0 { + if strings.TrimSpace(command) == "" { return nil } - cmd := exec.Command(args[0], args[1:]...) + cmd := exec.Command("sh", "-c", command) cmd.Dir = workDir cmd.Env = env cmd.Stdout = os.Stdout @@ -182,12 +212,11 @@ func (b *Builder) executeCommand(workDir, command string, env []string) error { // executeCommandWithOutput executes a command and returns output func (b *Builder) executeCommandWithOutput(workDir, command string, env []string) (string, error) { - args := strings.Fields(command) - if len(args) == 0 { + if strings.TrimSpace(command) == "" { return "", nil } - cmd := exec.Command(args[0], args[1:]...) + cmd := exec.Command("sh", "-c", command) cmd.Dir = workDir cmd.Env = env @@ -217,10 +246,10 @@ func (b *Builder) GetBuildInfo(recipe *recipe.Recipe) (*BuildInfo, error) { stagingDir := recipe.GetStagingDir(b.workDir) info := &BuildInfo{ - Recipe: recipe, - SourceDir: sourceDir, - StagingDir: stagingDir, - SourceExists: false, + Recipe: recipe, + SourceDir: sourceDir, + StagingDir: stagingDir, + SourceExists: false, StagingExists: false, } @@ -280,7 +309,7 @@ func (b *Builder) SetWorkDir(workDir string) { } // substituteVariables substitutes variables in command strings -func (b *Builder) substituteVariables(cmdStr string, recipe *recipe.Recipe, sourceDir, stagingDir string) string { +func (b *Builder) substituteVariables(cmdStr, sourceDir, stagingDir string) string { // Get number of CPU cores jobs := runtime.NumCPU() @@ -288,12 +317,15 @@ func (b *Builder) substituteVariables(cmdStr string, recipe *recipe.Recipe, sour cmdStr = strings.ReplaceAll(cmdStr, "${jobs}", fmt.Sprintf("%d", jobs)) cmdStr = strings.ReplaceAll(cmdStr, "${pkgdir}", stagingDir) cmdStr = strings.ReplaceAll(cmdStr, "${srcdir}", sourceDir) + cmdStr = strings.ReplaceAll(cmdStr, "{{jobs}}", fmt.Sprintf("%d", jobs)) + cmdStr = strings.ReplaceAll(cmdStr, "{{pkgdir}}", stagingDir) + cmdStr = strings.ReplaceAll(cmdStr, "{{srcdir}}", sourceDir) return cmdStr } // packageFiles runs the package commands -func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir, packageDir string) error { +func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir string) error { // Find the source directory (usually the first subdirectory) srcPath, err := b.findSourceDirectory(sourceDir) if err != nil { @@ -301,13 +333,13 @@ func (b *Builder) packageFiles(recipe *recipe.Recipe, sourceDir, stagingDir, pac } // Set up environment - env := b.buildEnvironment(recipe, stagingDir) + env := b.buildEnvironment(stagingDir) // Execute package commands - for i, cmd := range recipe.Package.Commands { + for i, cmd := range recipe.Install { // Substitute variables in command - cmd = b.substituteVariables(cmd, recipe, sourceDir, stagingDir) - + cmd = b.substituteVariables(cmd, sourceDir, stagingDir) + if err := b.executeCommand(srcPath, cmd, env); err != nil { return fmt.Errorf("package command %d failed: %w", i+1, err) } diff --git a/pkg/fetcher/fetcher.go b/pkg/fetcher/fetcher.go index 99acbe6..0575594 100644 --- a/pkg/fetcher/fetcher.go +++ b/pkg/fetcher/fetcher.go @@ -1,14 +1,19 @@ package fetcher import ( + "bufio" "crypto/sha256" "fmt" "io" "net/http" + neturl "net/url" "os" + "os/exec" "path/filepath" + "strings" "github.com/mholt/archiver/v3" + "golang.org/x/sync/errgroup" ) // Fetcher handles downloading and extracting sources @@ -23,15 +28,13 @@ func NewFetcher(cacheDir string) *Fetcher { } } -// Download downloads a file from URL and verifies checksum +// Download downloads a file from URL and verifies checksum when provided. func (f *Fetcher) Download(url, expectedHash string) (string, error) { // Validate input parameters if url == "" { return "", fmt.Errorf("URL cannot be empty") } - if expectedHash == "" { - return "", fmt.Errorf("expected hash cannot be empty") - } + expectedHash = strings.ToLower(strings.TrimSpace(expectedHash)) filename := filepath.Base(url) if filename == "" { @@ -40,9 +43,15 @@ func (f *Fetcher) Download(url, expectedHash string) (string, error) { cachePath := filepath.Join(f.cacheDir, filename) - // Check if already downloaded and valid - if f.isValidCache(cachePath, expectedHash) { - return cachePath, nil + // Check if already downloaded and valid. + if expectedHash != "" { + if f.isValidCache(cachePath, expectedHash) { + return cachePath, nil + } + } else { + if _, err := os.Stat(cachePath); err == nil { + return cachePath, nil + } } // Create cache directory if it doesn't exist @@ -66,22 +75,29 @@ func (f *Fetcher) Download(url, expectedHash string) (string, error) { if err != nil { return "", fmt.Errorf("failed to create temp file: %w", err) } - defer tmpFile.Close() // Download to temp file hasher := sha256.New() - if _, err := io.Copy(tmpFile, resp.Body); err != nil { + if _, err := io.Copy(io.MultiWriter(tmpFile, hasher), resp.Body); err != nil { + _ = tmpFile.Close() return "", fmt.Errorf("failed to download file: %w", err) } + if err := tmpFile.Close(); err != nil { + return "", fmt.Errorf("failed to close temp file: %w", err) + } // Verify checksum - calculatedHash := fmt.Sprintf("%x", hasher.Sum(nil)) - if calculatedHash != expectedHash { - return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, calculatedHash) + if expectedHash != "" { + calculatedHash := fmt.Sprintf("%x", hasher.Sum(nil)) + if calculatedHash != expectedHash { + _ = os.Remove(tmpFile.Name()) + return "", fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, calculatedHash) + } } // Move temp file to final location if err := os.Rename(tmpFile.Name(), cachePath); err != nil { + _ = os.Remove(tmpFile.Name()) return "", fmt.Errorf("failed to move downloaded file: %w", err) } @@ -128,36 +144,28 @@ func (f *Fetcher) ApplyPatches(sourceDir string, patchFiles []string) error { // applyPatch applies a single patch file func (f *Fetcher) applyPatch(sourceDir, patchFile string) error { - // This is a simplified patch application - // In a real implementation, you'd want to use the 'patch' command - // For now, we'll just copy the patch file to the source directory - patchDest := filepath.Join(sourceDir, filepath.Base(patchFile)) - if err := copyFile(patchFile, patchDest); err != nil { - return fmt.Errorf("failed to copy patch file: %w", err) - } - return nil -} + patchFile = filepath.Clean(patchFile) -// copyFile copies a file from src to dst -func copyFile(src, dst string) error { - sourceFile, err := os.Open(src) - if err != nil { - return err + // Try common strip levels used by patch files. + for _, stripLevel := range []string{"1", "0"} { + cmd := exec.Command("patch", "-p"+stripLevel, "-i", patchFile) + cmd.Dir = sourceDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err == nil { + return nil + } } - defer sourceFile.Close() - destFile, err := os.Create(dst) - if err != nil { - return err - } - defer destFile.Close() - - _, err = io.Copy(destFile, sourceFile) - return err + return fmt.Errorf("failed to apply patch with -p1 and -p0: %s", patchFile) } // DownloadAndExtract downloads and extracts source func (f *Fetcher) DownloadAndExtract(url, expectedHash, destDir string) error { + if isDebianDSCURL(url) { + return f.downloadAndExtractFromDebianDSC(url, expectedHash, destDir) + } + archivePath, err := f.Download(url, expectedHash) if err != nil { return err @@ -166,6 +174,139 @@ func (f *Fetcher) DownloadAndExtract(url, expectedHash, destDir string) error { return f.Extract(archivePath, destDir) } +func (f *Fetcher) downloadAndExtractFromDebianDSC(dscURL, dscHash, destDir string) error { + dscPath, err := f.Download(dscURL, dscHash) + if err != nil { + return fmt.Errorf("failed to download debian dsc: %w", err) + } + + entries, err := parseDebianDSCSHA256Entries(dscPath) + if err != nil { + return err + } + + origEntries := make([]debianDSCEntry, 0, len(entries)) + for _, entry := range entries { + if isDebianOrigArchive(entry.Name) { + origEntries = append(origEntries, entry) + } + } + if len(origEntries) == 0 { + return fmt.Errorf("debian dsc does not contain upstream orig archive") + } + + base, err := neturl.Parse(dscURL) + if err != nil { + return fmt.Errorf("invalid dsc URL %q: %w", dscURL, err) + } + + for _, entry := range origEntries { + ref, err := neturl.Parse(entry.Name) + if err != nil { + return fmt.Errorf("invalid dsc entry filename %q: %w", entry.Name, err) + } + + fileURL := base.ResolveReference(ref).String() + archivePath, err := f.Download(fileURL, entry.SHA256) + if err != nil { + return fmt.Errorf("failed to download upstream source %s: %w", entry.Name, err) + } + if err := f.Extract(archivePath, destDir); err != nil { + return fmt.Errorf("failed to extract upstream source %s: %w", entry.Name, err) + } + } + + return nil +} + +type debianDSCEntry struct { + Name string + SHA256 string +} + +func parseDebianDSCSHA256Entries(path string) ([]debianDSCEntry, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open dsc file %s: %w", path, err) + } + defer file.Close() + + var entries []debianDSCEntry + scanner := bufio.NewScanner(file) + inSHA256Section := false + + for scanner.Scan() { + line := strings.TrimRight(scanner.Text(), "\r") + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + if !inSHA256Section { + if strings.HasPrefix(trimmed, "Checksums-Sha256:") { + inSHA256Section = true + } + continue + } + + if !isIndentedLine(line) { + break + } + + fields := strings.Fields(trimmed) + if len(fields) < 3 { + return nil, fmt.Errorf("invalid Checksums-Sha256 entry in dsc: %q", line) + } + entries = append(entries, debianDSCEntry{ + SHA256: strings.ToLower(fields[0]), + Name: fields[2], + }) + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read dsc file %s: %w", path, err) + } + if len(entries) == 0 { + return nil, fmt.Errorf("debian dsc missing Checksums-Sha256 entries") + } + + return entries, nil +} + +func isIndentedLine(line string) bool { + if line == "" { + return false + } + return line[0] == ' ' || line[0] == '\t' +} + +func isDebianOrigArchive(name string) bool { + idx := strings.Index(name, ".orig") + if idx < 0 { + return false + } + + rest := name[idx+len(".orig"):] + if strings.HasPrefix(rest, ".tar.") { + return true + } + if strings.HasPrefix(rest, "-") && strings.Contains(rest, ".tar.") { + return true + } + return false +} + +func isDebianDSCURL(raw string) bool { + raw = strings.TrimSpace(strings.ToLower(raw)) + if raw == "" { + return false + } + if idx := strings.IndexAny(raw, "?#"); idx >= 0 { + raw = raw[:idx] + } + return strings.HasSuffix(raw, ".dsc") +} + // DownloadMultiple downloads multiple files concurrently func (f *Fetcher) DownloadMultiple(urls []string, hashes []string) ([]string, error) { if len(urls) != len(hashes) { @@ -173,12 +314,21 @@ func (f *Fetcher) DownloadMultiple(urls []string, hashes []string) ([]string, er } paths := make([]string, len(urls)) + var g errgroup.Group for i, url := range urls { - path, err := f.Download(url, hashes[i]) - if err != nil { - return nil, err - } - paths[i] = path + i, url := i, url + g.Go(func() error { + path, err := f.Download(url, hashes[i]) + if err != nil { + return err + } + paths[i] = path + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err } return paths, nil diff --git a/pkg/fetcher/fetcher_test.go b/pkg/fetcher/fetcher_test.go new file mode 100644 index 0000000..0dcd692 --- /dev/null +++ b/pkg/fetcher/fetcher_test.go @@ -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) +} diff --git a/pkg/installer/installer.go b/pkg/installer/installer.go index 9e7deff..5d0ca61 100644 --- a/pkg/installer/installer.go +++ b/pkg/installer/installer.go @@ -1,24 +1,29 @@ package installer import ( - "bufio" "fmt" "io" "os" "path/filepath" + "sort" "strings" + "syscall" "zsvo/pkg/packager" "zsvo/pkg/types" ) -// Installer handles installing and removing packages +// Installer handles installing and removing packages. type Installer struct { rootDir string pkgDB string } -// NewInstaller creates a new installer +// RemoveOptions controls package removal behavior. +type RemoveOptions struct { + Cascade bool +} + func NewInstaller(rootDir string) *Installer { return &Installer{ rootDir: rootDir, @@ -26,76 +31,740 @@ func NewInstaller(rootDir string) *Installer { } } -// Install installs a package +// Install installs a single package archive produced by zsvo build. func (i *Installer) Install(packagePath string) error { - // Extract package to temporary directory - tmpDir, err := os.MkdirTemp("", "install-") - if err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) - } - defer os.RemoveAll(tmpDir) - - // Extract package - pkg := packager.NewPackager(i.rootDir) - if err := pkg.Extract(packagePath, tmpDir); err != nil { - return fmt.Errorf("failed to extract package: %w", err) - } - - // Read package info - pkgInfo, err := pkg.ReadPkgInfo(packagePath) - if err != nil { - return fmt.Errorf("failed to read package info: %w", err) - } - - // Check dependencies - if err := i.checkDependencies(pkgInfo.Dependencies); err != nil { - return fmt.Errorf("dependency check failed: %w", err) - } - - // Install files - if err := i.installFiles(tmpDir, pkgInfo); err != nil { - return fmt.Errorf("failed to install files: %w", err) - } - - // Register package - if err := i.registerPackage(pkgInfo); err != nil { - return fmt.Errorf("failed to register package: %w", err) - } - - return nil + return i.InstallMany([]string{packagePath}) } -// Remove removes a package +// InstallMany installs multiple package archives in one transaction. +func (i *Installer) InstallMany(packagePaths []string) error { + if len(packagePaths) == 0 { + return fmt.Errorf("no packages provided") + } + + return i.withDBLock(true, func() error { + return i.installManyNoLock(packagePaths) + }) +} + +// Upgrade upgrades packages from local package files. +func (i *Installer) Upgrade(packagePaths []string) error { + return i.InstallMany(packagePaths) +} + +// PlanRemove returns removal plan without changing filesystem. +func (i *Installer) PlanRemove(packageNames []string, options RemoveOptions) ([]string, error) { + var plan []string + if err := i.withDBLock(false, func() error { + var err error + plan, err = i.planRemoveNoLock(packageNames, options) + return err + }); err != nil { + return nil, err + } + return plan, nil +} + +// Remove removes a single installed package. func (i *Installer) Remove(packageName string) error { - // Get package info - pkgInfo, err := i.getPackageInfo(packageName) - if err != nil { - return fmt.Errorf("package not found: %w", err) - } - - // Remove files - if err := i.removeFiles(pkgInfo); err != nil { - return fmt.Errorf("failed to remove files: %w", err) - } - - // Unregister package - if err := i.unregisterPackage(packageName); err != nil { - return fmt.Errorf("failed to unregister package: %w", err) - } - - return nil + _, err := i.RemoveMany([]string{packageName}, RemoveOptions{}) + return err } -// ListInstalled lists all installed packages +// RemoveMany removes one or more installed packages in one transaction. +func (i *Installer) RemoveMany(packageNames []string, options RemoveOptions) ([]string, error) { + var removed []string + if err := i.withDBLock(true, func() error { + plan, err := i.planRemoveNoLock(packageNames, options) + if err != nil { + return err + } + + removed, err = i.removeManyWithPlanNoLock(plan) + return err + }); err != nil { + return nil, err + } + + return removed, nil +} + +// ListInstalled lists all installed packages. func (i *Installer) ListInstalled() ([]string, error) { var packages []string + if err := i.withDBLock(false, func() error { + var err error + packages, err = i.listInstalledNoLock() + return err + }); err != nil { + return nil, err + } + + return packages, nil +} + +// ListOrphans returns installed packages that are not required by others. +func (i *Installer) ListOrphans() ([]string, error) { + var orphans []string + if err := i.withDBLock(false, func() error { + infos, err := i.installedPkgInfosNoLock() + if err != nil { + return err + } + + orphans = listOrphansFromInfos(infos) + return nil + }); err != nil { + return nil, err + } + + return orphans, nil +} + +// GetPackageInfo returns metadata for an installed package. +func (i *Installer) GetPackageInfo(packageName string) (*types.PkgInfo, error) { + var pkgInfo *types.PkgInfo + if err := i.withDBLock(false, func() error { + var err error + pkgInfo, err = i.getPackageInfoNoLock(packageName) + return err + }); err != nil { + return nil, err + } + + return pkgInfo, nil +} + +func (i *Installer) installManyNoLock(packagePaths []string) error { + candidates, err := i.readInstallCandidatesNoLock(packagePaths) + if err != nil { + return err + } + + installedInfos, err := i.installedPkgInfosNoLock() + if err != nil { + return err + } + + txDir, err := os.MkdirTemp("", "install-many-") + if err != nil { + return fmt.Errorf("failed to create transaction directory: %w", err) + } + 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 + } + + state, err := i.installPackageTxNoLock(candidate.path, txDir, installedInfos) + if err != nil { + if rbErr := i.rollbackInstallTransactionNoLock(applied); rbErr != nil { + return fmt.Errorf("install failed for %s: %w (rollback error: %v)", candidate.info.Name, err, rbErr) + } + return fmt.Errorf("install failed for %s: %w", candidate.info.Name, err) + } + + 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 +} + +func (i *Installer) readInstallCandidatesNoLock(packagePaths []string) ([]installCandidate, error) { + p := packager.NewPackager(i.rootDir) + + candidates := make([]installCandidate, 0, len(packagePaths)) + seenNames := make(map[string]string, len(packagePaths)) + for _, packagePath := range packagePaths { + if strings.TrimSpace(packagePath) == "" { + return nil, fmt.Errorf("package path cannot be empty") + } + + pkgInfo, err := p.ReadPkgInfo(packagePath) + if err != nil { + return nil, fmt.Errorf("failed to read package metadata from %s: %w", packagePath, err) + } + if err := validateSimpleDependencies(pkgInfo.Dependencies); err != nil { + return nil, fmt.Errorf("invalid dependencies in %s: %w", packagePath, err) + } + + if prev, exists := seenNames[pkgInfo.Name]; exists { + return nil, fmt.Errorf("duplicate package %s in transaction: %s and %s", pkgInfo.Name, prev, packagePath) + } + seenNames[pkgInfo.Name] = packagePath + + candidates = append(candidates, installCandidate{path: packagePath, info: pkgInfo}) + } + + return candidates, nil +} + +func (i *Installer) installPackageTxNoLock(packagePath, txDir string, installedInfos map[string]*types.PkgInfo) (*installTransactionState, error) { + pkgTxDir, err := os.MkdirTemp(txDir, "pkg-") + if err != nil { + return nil, fmt.Errorf("failed to create package transaction directory: %w", err) + } + + extractDir := filepath.Join(pkgTxDir, "extract") + p := packager.NewPackager(i.rootDir) + if err := p.Extract(packagePath, extractDir); err != nil { + return nil, fmt.Errorf("failed to extract package: %w", err) + } + + pkgInfo, err := p.ReadPkgInfo(packagePath) + if err != nil { + return nil, fmt.Errorf("failed to read package metadata: %w", err) + } + if err := validateSimpleDependencies(pkgInfo.Dependencies); err != nil { + return nil, err + } + + if err := checkDependenciesAgainstInstalled(pkgInfo.Dependencies, installedInfos, pkgInfo.Name); err != nil { + return nil, err + } + + if err := i.checkFileOwnershipNoLock(pkgInfo, installedInfos); err != nil { + return nil, err + } + + extractRoot, err := i.resolveExtractRoot(extractDir, pkgInfo) + if err != nil { + return nil, fmt.Errorf("failed to resolve package root: %w", err) + } + + state := &installTransactionState{pkgInfo: pkgInfo, txDir: pkgTxDir} + + // Same-name install is treated as upgrade/reinstall. + if oldInfo, exists := installedInfos[pkgInfo.Name]; exists { + replacedBackupRoot := filepath.Join(pkgTxDir, "replaced") + backups, err := i.removeFiles(oldInfo, replacedBackupRoot) + if err != nil { + return nil, fmt.Errorf("failed to remove old package %s files: %w", pkgInfo.Name, err) + } + if err := i.unregisterPackage(pkgInfo.Name); err != nil { + _ = i.rollbackRemove(backups) + return nil, fmt.Errorf("failed to unregister old package %s: %w", pkgInfo.Name, err) + } + + state.replaced = &removeTransactionState{ + pkgName: pkgInfo.Name, + info: oldInfo, + backups: backups, + } + } + + backupRoot := filepath.Join(pkgTxDir, "new") + installedPaths, backups, err := i.installFiles(extractRoot, pkgInfo, backupRoot) + if err != nil { + _ = i.rollbackInstall(installedPaths, backups) + if state.replaced != nil { + _ = i.rollbackRemove(state.replaced.backups) + _ = i.registerPackage(state.replaced.info) + } + return nil, fmt.Errorf("failed to install files: %w", err) + } + state.installedPaths = installedPaths + state.newBackups = backups + + if err := i.registerPackage(pkgInfo); err != nil { + _ = i.rollbackInstall(installedPaths, backups) + if state.replaced != nil { + _ = i.rollbackRemove(state.replaced.backups) + _ = i.registerPackage(state.replaced.info) + } + return nil, fmt.Errorf("failed to register package: %w", err) + } + + return state, nil +} + +func (i *Installer) rollbackInstallTransactionNoLock(applied []installTransactionState) error { + if len(applied) == 0 { + return nil + } + + var errs []string + for idx := len(applied) - 1; idx >= 0; idx-- { + state := applied[idx] + + rollbackRoot := filepath.Join(state.txDir, "rollback-new") + backups, err := i.removeFiles(state.pkgInfo, rollbackRoot) + if err != nil { + errs = append(errs, fmt.Sprintf("failed to remove %s during rollback: %v", state.pkgInfo.Name, err)) + continue + } + if err := i.unregisterPackage(state.pkgInfo.Name); err != nil { + _ = i.rollbackRemove(backups) + errs = append(errs, fmt.Sprintf("failed to unregister %s during rollback: %v", state.pkgInfo.Name, err)) + continue + } + + if err := i.rollbackInstall(state.installedPaths, state.newBackups); err != nil { + errs = append(errs, fmt.Sprintf("failed to rollback overwritten paths for %s: %v", state.pkgInfo.Name, err)) + } + + if state.replaced != nil { + if err := i.rollbackRemove(state.replaced.backups); err != nil { + errs = append(errs, fmt.Sprintf("failed to restore old files for %s: %v", state.replaced.pkgName, err)) + } + if err := i.registerPackage(state.replaced.info); err != nil { + errs = append(errs, fmt.Sprintf("failed to re-register %s: %v", state.replaced.pkgName, err)) + } + } + } + + if len(errs) > 0 { + return fmt.Errorf("install transaction rollback failed: %s", strings.Join(errs, "; ")) + } + return nil +} + +func (i *Installer) planRemoveNoLock(packageNames []string, options RemoveOptions) ([]string, error) { + names, err := normalizePackageNames(packageNames) + if err != nil { + return nil, err + } + + infos, err := i.installedPkgInfosNoLock() + if err != nil { + return nil, err + } + + removeSet := make(map[string]struct{}, len(names)) + for _, name := range names { + if _, ok := infos[name]; !ok { + return nil, fmt.Errorf("package %s not found in database", name) + } + removeSet[name] = struct{}{} + } + + if options.Cascade { + for { + broken, err := brokenPackagesAfterRemoval(infos, removeSet) + if err != nil { + return nil, err + } + + changed := false + for _, name := range broken { + if _, exists := removeSet[name]; exists { + continue + } + removeSet[name] = struct{}{} + changed = true + } + + if !changed { + break + } + } + } else { + broken, err := brokenPackagesAfterRemoval(infos, removeSet) + if err != nil { + return nil, err + } + if len(broken) > 0 { + return nil, fmt.Errorf("cannot remove %s: required by %s", strings.Join(names, ", "), strings.Join(broken, ", ")) + } + } + + plan := make([]string, 0, len(removeSet)) + for name := range removeSet { + plan = append(plan, name) + } + sort.Strings(plan) + return plan, nil +} + +func (i *Installer) removeManyWithPlanNoLock(plan []string) ([]string, error) { + if len(plan) == 0 { + return nil, nil + } + + txDir, err := os.MkdirTemp("", "remove-many-") + if err != nil { + return nil, fmt.Errorf("failed to create remove transaction directory: %w", err) + } + defer os.RemoveAll(txDir) + + states := make([]removeTransactionState, 0, len(plan)) + for _, packageName := range plan { + state, err := i.removePackageTxNoLock(packageName, txDir) + if err != nil { + if rbErr := i.rollbackRemoveTransactionNoLock(states); rbErr != nil { + return nil, fmt.Errorf("failed to remove package %s: %w (rollback error: %v)", packageName, err, rbErr) + } + return nil, fmt.Errorf("failed to remove package %s: %w", packageName, err) + } + states = append(states, *state) + } + + return append([]string(nil), plan...), nil +} + +func (i *Installer) removePackageTxNoLock(packageName, txDir string) (*removeTransactionState, error) { + pkgInfo, err := i.getPackageInfoNoLock(packageName) + if err != nil { + return nil, fmt.Errorf("package not found: %w", err) + } + + backupRoot := filepath.Join(txDir, packageName) + backups, err := i.removeFiles(pkgInfo, backupRoot) + if err != nil { + return nil, fmt.Errorf("failed to remove files: %w", err) + } + if err := i.unregisterPackage(packageName); err != nil { + _ = i.rollbackRemove(backups) + return nil, fmt.Errorf("failed to unregister package: %w", err) + } + + return &removeTransactionState{pkgName: packageName, info: pkgInfo, backups: backups}, nil +} + +func (i *Installer) rollbackRemoveTransactionNoLock(states []removeTransactionState) error { + if len(states) == 0 { + return nil + } + + var errs []string + for idx := len(states) - 1; idx >= 0; idx-- { + state := states[idx] + if err := i.rollbackRemove(state.backups); err != nil { + errs = append(errs, err.Error()) + } + if err := i.registerPackage(state.info); err != nil { + errs = append(errs, fmt.Sprintf("failed to re-register %s: %v", state.pkgName, err)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("remove transaction rollback failed: %s", strings.Join(errs, "; ")) + } + return nil +} + +func (i *Installer) checkFileOwnershipNoLock(pkgInfo *types.PkgInfo, installedInfos map[string]*types.PkgInfo) error { + ownerByPath := make(map[string]string) + for ownerName, info := range installedInfos { + for _, file := range info.Files { + relPath, err := sanitizePackagePath(file) + if err != nil { + return err + } + ownerByPath[relPath] = ownerName + } + } + + rootAbs, err := filepath.Abs(filepath.Clean(i.rootDir)) + if err != nil { + return fmt.Errorf("failed to resolve root directory: %w", err) + } + + for _, file := range pkgInfo.Files { + relPath, err := sanitizePackagePath(file) + if err != nil { + return err + } + + if owner, exists := ownerByPath[relPath]; exists && owner != pkgInfo.Name { + return fmt.Errorf("file conflict: %s is owned by %s", relPath, owner) + } + + dst, err := safeJoinRoot(rootAbs, relPath) + if err != nil { + return err + } + + if _, err := os.Lstat(dst); err == nil { + if _, owned := ownerByPath[relPath]; !owned { + return fmt.Errorf("file conflict: %s exists in filesystem and is not owned by any package", relPath) + } + } else if !os.IsNotExist(err) { + return err + } + } + + return nil +} + +func (i *Installer) installFiles(extractRoot string, pkgInfo *types.PkgInfo, backupRoot string) ([]string, []pathBackup, error) { + extractRoot = filepath.Clean(extractRoot) + rootAbs, err := filepath.Abs(filepath.Clean(i.rootDir)) + if err != nil { + return nil, nil, fmt.Errorf("failed to resolve root directory: %w", err) + } + + if err := os.MkdirAll(rootAbs, 0o755); err != nil { + return nil, nil, fmt.Errorf("failed to create root directory %s: %w", rootAbs, err) + } + + installedPaths := make([]string, 0, len(pkgInfo.Files)) + backups := make([]pathBackup, 0, len(pkgInfo.Files)) + for _, file := range pkgInfo.Files { + relPath, err := sanitizePackagePath(file) + if err != nil { + return installedPaths, backups, err + } + + src := filepath.Join(extractRoot, relPath) + dst, err := safeJoinRoot(rootAbs, relPath) + if err != nil { + return installedPaths, backups, err + } + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return installedPaths, backups, fmt.Errorf("failed to create directory %s: %w", filepath.Dir(dst), err) + } + + if _, err := os.Lstat(dst); err == nil { + backupPath := filepath.Join(backupRoot, relPath) + if err := os.MkdirAll(filepath.Dir(backupPath), 0o755); err != nil { + return installedPaths, backups, err + } + if err := i.clonePath(dst, backupPath); err != nil { + return installedPaths, backups, fmt.Errorf("failed to backup %s: %w", relPath, err) + } + backups = append(backups, pathBackup{originalPath: dst, backupPath: backupPath}) + } else if !os.IsNotExist(err) { + return installedPaths, backups, err + } + + if err := i.clonePath(src, dst); err != nil { + return installedPaths, backups, fmt.Errorf("failed to copy %s: %w", relPath, err) + } + + installedPaths = append(installedPaths, dst) + } + + return installedPaths, backups, nil +} + +func (i *Installer) rollbackInstall(installedPaths []string, backups []pathBackup) error { + var errs []string + + seen := make(map[string]struct{}, len(installedPaths)) + for idx := len(installedPaths) - 1; idx >= 0; idx-- { + path := installedPaths[idx] + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + errs = append(errs, fmt.Sprintf("remove %s: %v", path, err)) + } + } + + for idx := len(backups) - 1; idx >= 0; idx-- { + backup := backups[idx] + if err := os.MkdirAll(filepath.Dir(backup.originalPath), 0o755); err != nil { + errs = append(errs, fmt.Sprintf("mkdir %s: %v", filepath.Dir(backup.originalPath), err)) + continue + } + if err := i.clonePath(backup.backupPath, backup.originalPath); err != nil { + errs = append(errs, fmt.Sprintf("restore %s: %v", backup.originalPath, err)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("install rollback failed: %s", strings.Join(errs, "; ")) + } + return nil +} + +func (i *Installer) resolveExtractRoot(tmpDir string, pkgInfo *types.PkgInfo) (string, error) { + if filesExistUnderRoot(tmpDir, pkgInfo.Files) { + return tmpDir, nil + } + + entries, err := os.ReadDir(tmpDir) + if err != nil { + return "", err + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + candidate := filepath.Join(tmpDir, entry.Name()) + if filesExistUnderRoot(candidate, pkgInfo.Files) { + return candidate, nil + } + } + + return "", fmt.Errorf("package archive layout does not match file list") +} + +func filesExistUnderRoot(root string, files []string) bool { + for _, file := range files { + relPath, err := sanitizePackagePath(file) + if err != nil { + return false + } + + if _, err := os.Lstat(filepath.Join(root, relPath)); err != nil { + return false + } + } + return true +} + +// removeFiles removes package files and stores backups for rollback. +func (i *Installer) removeFiles(pkgInfo *types.PkgInfo, backupRoot string) ([]pathBackup, error) { + rootAbs, err := filepath.Abs(filepath.Clean(i.rootDir)) + if err != nil { + return nil, fmt.Errorf("failed to resolve root directory: %w", err) + } + + backups := make([]pathBackup, 0, len(pkgInfo.Files)) + for _, file := range pkgInfo.Files { + relPath, err := sanitizePackagePath(file) + if err != nil { + return backups, err + } + + path, err := safeJoinRoot(rootAbs, relPath) + if err != nil { + return backups, err + } + + if _, err := os.Lstat(path); os.IsNotExist(err) { + continue + } else if err != nil { + return backups, err + } + + backupPath := filepath.Join(backupRoot, relPath) + if err := os.MkdirAll(filepath.Dir(backupPath), 0o755); err != nil { + return backups, err + } + if err := i.clonePath(path, backupPath); err != nil { + return backups, fmt.Errorf("failed to backup %s: %w", relPath, err) + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return backups, fmt.Errorf("failed to remove %s: %w", relPath, err) + } + + backups = append(backups, pathBackup{originalPath: path, backupPath: backupPath}) + } + + return backups, nil +} + +func (i *Installer) rollbackRemove(backups []pathBackup) error { + var errs []string + + for idx := len(backups) - 1; idx >= 0; idx-- { + backup := backups[idx] + if err := os.MkdirAll(filepath.Dir(backup.originalPath), 0o755); err != nil { + errs = append(errs, fmt.Sprintf("mkdir %s: %v", filepath.Dir(backup.originalPath), err)) + continue + } + if err := i.clonePath(backup.backupPath, backup.originalPath); err != nil { + errs = append(errs, fmt.Sprintf("restore %s: %v", backup.originalPath, err)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("remove rollback failed: %s", strings.Join(errs, "; ")) + } + return nil +} + +func (i *Installer) registerPackage(pkgInfo *types.PkgInfo) error { + if err := pkgInfo.Validate(); err != nil { + return err + } + if err := validateSimpleDependencies(pkgInfo.Dependencies); err != nil { + return err + } + + if err := os.MkdirAll(i.pkgDB, 0o755); err != nil { + return fmt.Errorf("failed to create package database: %w", err) + } + + pkgDir := filepath.Join(i.pkgDB, pkgInfo.Name) + if err := os.MkdirAll(pkgDir, 0o755); err != nil { + return fmt.Errorf("failed to create package database directory: %w", err) + } + + pkgInfoFile := filepath.Join(pkgDir, types.PackageMetadataFile) + file, err := os.Create(pkgInfoFile) + if err != nil { + return err + } + defer file.Close() + + return types.WritePkgInfo(file, pkgInfo) +} + +func (i *Installer) unregisterPackage(packageName string) error { + return os.RemoveAll(filepath.Join(i.pkgDB, packageName)) +} + +func (i *Installer) getPackageInfoNoLock(packageName string) (*types.PkgInfo, error) { + pkgDir := filepath.Join(i.pkgDB, packageName) + pkgInfoFile := filepath.Join(pkgDir, types.PackageMetadataFile) + + if _, err := os.Stat(pkgDir); os.IsNotExist(err) { + return nil, fmt.Errorf("package %s not found in database", packageName) + } + if _, err := os.Stat(pkgInfoFile); os.IsNotExist(err) { + return nil, fmt.Errorf("package metadata file not found for %s", packageName) + } + + file, err := os.Open(pkgInfoFile) + if err != nil { + return nil, err + } + defer file.Close() + + pkgInfo, err := types.ReadPkgInfo(file) + if err != nil { + return nil, fmt.Errorf("invalid package metadata for %s: %w", packageName, err) + } + + return pkgInfo, nil +} + +func (i *Installer) listInstalledNoLock() ([]string, error) { + var packages []string - // Check if pkgdb directory exists if _, err := os.Stat(i.pkgDB); os.IsNotExist(err) { return packages, nil } - // Read pkgdb directory entries, err := os.ReadDir(i.pkgDB) if err != nil { return nil, fmt.Errorf("failed to read package database: %w", err) @@ -106,294 +775,387 @@ func (i *Installer) ListInstalled() ([]string, error) { packages = append(packages, entry.Name()) } } - + sort.Strings(packages) return packages, nil } -// GetPackageInfo gets information about an installed package -func (i *Installer) GetPackageInfo(packageName string) (*types.PkgInfo, error) { - return i.getPackageInfo(packageName) +func (i *Installer) installedPkgInfosNoLock() (map[string]*types.PkgInfo, error) { + installed, err := i.listInstalledNoLock() + if err != nil { + return nil, err + } + + infos := make(map[string]*types.PkgInfo, len(installed)) + for _, pkgName := range installed { + info, err := i.getPackageInfoNoLock(pkgName) + if err != nil { + return nil, err + } + infos[pkgName] = info + } + + return infos, nil } -// checkDependencies checks if all dependencies are installed -func (i *Installer) checkDependencies(deps []string) error { - installed, err := i.ListInstalled() +func (i *Installer) clonePath(src, dst string) error { + info, err := os.Lstat(src) if err != nil { return err } + switch { + case info.Mode()&os.ModeSymlink != 0: + target, err := os.Readlink(src) + if err != nil { + return err + } + if err := removePathIfExists(dst); err != nil { + return err + } + return os.Symlink(target, dst) + case info.IsDir(): + return os.MkdirAll(dst, info.Mode().Perm()) + case info.Mode().IsRegular(): + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + return err + } + defer destFile.Close() + + if _, err := io.Copy(destFile, sourceFile); err != nil { + return err + } + return nil + default: + return fmt.Errorf("unsupported file type: %s", src) + } +} + +func removePathIfExists(path string) error { + if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func validateSimpleDependencies(deps []string) error { for _, dep := range deps { - found := false - for _, installedPkg := range installed { - if installedPkg == dep { - found = true + if depNameFromConstraint(dep) == "" { + return fmt.Errorf("invalid dependency: %q", dep) + } + } + return nil +} + +func depsSatisfied(deps []string, installedInfos map[string]*types.PkgInfo, selfName string) bool { + for _, dep := range deps { + depName := depNameFromConstraint(dep) + if depName == "" { + return false + } + if depName == selfName { + continue + } + if _, ok := installedInfos[depName]; !ok { + return false + } + } + return true +} + +func missingDeps(deps []string, installedInfos map[string]*types.PkgInfo, selfName string) []string { + missing := make([]string, 0) + seen := make(map[string]struct{}) + for _, dep := range deps { + depName := depNameFromConstraint(dep) + if depName == "" || depName == selfName { + continue + } + if _, ok := installedInfos[depName]; ok { + continue + } + if _, exists := seen[depName]; exists { + continue + } + seen[depName] = struct{}{} + missing = append(missing, depName) + } + sort.Strings(missing) + return missing +} + +func checkDependenciesAgainstInstalled(deps []string, installedInfos map[string]*types.PkgInfo, selfName string) error { + if depsSatisfied(deps, installedInfos, selfName) { + return nil + } + missing := missingDeps(deps, installedInfos, selfName) + if len(missing) == 0 { + return fmt.Errorf("dependency check failed") + } + 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") + } + + normalized := make([]string, 0, len(packageNames)) + seen := make(map[string]struct{}, len(packageNames)) + for _, packageName := range packageNames { + name := strings.TrimSpace(packageName) + if name == "" { + return nil, fmt.Errorf("package name cannot be empty") + } + if _, exists := seen[name]; exists { + return nil, fmt.Errorf("duplicate package in request: %s", name) + } + seen[name] = struct{}{} + normalized = append(normalized, name) + } + + sort.Strings(normalized) + return normalized, nil +} + +func brokenPackagesAfterRemoval(infos map[string]*types.PkgInfo, removeSet map[string]struct{}) ([]string, error) { + remaining := make(map[string]*types.PkgInfo, len(infos)) + for name, info := range infos { + if _, removed := removeSet[name]; removed { + continue + } + remaining[name] = info + } + + names := make([]string, 0, len(remaining)) + for name := range remaining { + names = append(names, name) + } + sort.Strings(names) + + broken := make([]string, 0) + 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) + } + if depName == pkgName { + continue + } + if _, ok := remaining[depName]; !ok { + broken = append(broken, pkgName) break } } - if !found { - return fmt.Errorf("dependency not installed: %s", dep) - } } - return nil + return broken, nil } -// installFiles installs files from package -func (i *Installer) installFiles(tmpDir string, pkgInfo *types.PkgInfo) error { - // Validate input paths - if tmpDir == "" { - return fmt.Errorf("temporary directory cannot be empty") - } - if i.rootDir == "" { - return fmt.Errorf("root directory cannot be empty") - } - - // Clean and normalize paths - tmpDir = filepath.Clean(tmpDir) - i.rootDir = filepath.Clean(i.rootDir) - - // Create root directory if it doesn't exist - if err := os.MkdirAll(i.rootDir, 0755); err != nil { - return fmt.Errorf("failed to create root directory %s: %w", i.rootDir, err) - } - - // Copy files to root directory - for _, file := range pkgInfo.Files { - // Validate file path - if file == "" { - return fmt.Errorf("file path cannot be empty") - } - - // Clean file path to prevent directory traversal - file = filepath.Clean(file) - if strings.HasPrefix(file, "..") { - return fmt.Errorf("invalid file path: %s", file) - } - - src := filepath.Join(tmpDir, file) - dst := filepath.Join(i.rootDir, file) - - // Create parent directory - if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", filepath.Dir(dst), err) - } - - // Copy file - if err := i.copyFile(src, dst); err != nil { - return fmt.Errorf("failed to copy file %s: %w", file, err) - } - } - - return nil -} - -// removeFiles removes files of a package -func (i *Installer) removeFiles(pkgInfo *types.PkgInfo) error { - for _, file := range pkgInfo.Files { - path := filepath.Join(i.rootDir, file) - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove file %s: %w", file, err) - } - } - return nil -} - -// registerPackage registers a package in the database -func (i *Installer) registerPackage(pkgInfo *types.PkgInfo) error { - // Create package database directory - pkgDir := filepath.Join(i.pkgDB, pkgInfo.Name) - if err := os.MkdirAll(pkgDir, 0755); err != nil { - return fmt.Errorf("failed to create package database directory: %w", err) - } - - // Write package info - pkgInfoFile := filepath.Join(pkgDir, ".pkginfo") - file, err := os.Create(pkgInfoFile) - if err != nil { - return err - } - defer file.Close() - - // Write package info in simple format - content := fmt.Sprintf( - "name = %q\nversion = %q\ndescription = %q\ndependencies = %v\nfiles = %v\ninstall_date = %q\n", - pkgInfo.Name, pkgInfo.Version, pkgInfo.Description, pkgInfo.Dependencies, pkgInfo.Files, pkgInfo.InstallDate, - ) - _, err = file.WriteString(content) - return err -} - -// unregisterPackage unregisters a package from the database -func (i *Installer) unregisterPackage(packageName string) error { - pkgDir := filepath.Join(i.pkgDB, packageName) - return os.RemoveAll(pkgDir) -} - -// getPackageInfo gets package info from database -func (i *Installer) getPackageInfo(packageName string) (*types.PkgInfo, error) { - pkgDir := filepath.Join(i.pkgDB, packageName) - pkgInfoFile := filepath.Join(pkgDir, ".pkginfo") - - // Check if package directory exists - if _, err := os.Stat(pkgDir); os.IsNotExist(err) { - return nil, fmt.Errorf("package %s not found in database", packageName) - } - - // Check if .pkginfo file exists - if _, err := os.Stat(pkgInfoFile); os.IsNotExist(err) { - return nil, fmt.Errorf("package info file not found for %s", packageName) - } - - file, err := os.Open(pkgInfoFile) - if err != nil { - return nil, err - } - defer file.Close() - - var pkgInfo types.PkgInfo - // Simple parser for our package info format - // In a real implementation, you'd want to use a proper TOML parser - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - // Remove quotes from value - value = strings.Trim(value, "\"") - - switch key { - case "name": - pkgInfo.Name = value - case "version": - pkgInfo.Version = value - case "description": - pkgInfo.Description = value - case "dependencies": - // Parse dependencies array - value = strings.Trim(value, "[]") - if value != "" { - pkgInfo.Dependencies = strings.Split(value, ",") - for i, dep := range pkgInfo.Dependencies { - pkgInfo.Dependencies[i] = strings.TrimSpace(strings.Trim(dep, "\"")) - } +func listOrphansFromInfos(infos map[string]*types.PkgInfo) []string { + required := make(map[string]struct{}) + for owner, info := range infos { + for _, dep := range info.Dependencies { + depName := depNameFromConstraint(dep) + if depName == "" || depName == owner { + continue } - 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, "\"")) - } + if _, ok := infos[depName]; ok { + required[depName] = struct{}{} } - case "install_date": - pkgInfo.InstallDate = value } } - - if err := scanner.Err(); err != nil { - return nil, err + + orphans := make([]string, 0) + for name := range infos { + if _, ok := required[name]; !ok { + orphans = append(orphans, name) + } } - - // Validate that we actually parsed some data - if pkgInfo.Name == "" { - return nil, fmt.Errorf("invalid package info: missing name") - } - - return &pkgInfo, nil + sort.Strings(orphans) + return orphans } -// copyFile copies a file from src to dst -func (i *Installer) copyFile(src, dst string) error { - sourceFile, err := os.Open(src) - if err != nil { +func sanitizePackagePath(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("file path cannot be empty") + } + if filepath.IsAbs(path) { + return "", fmt.Errorf("absolute paths are not allowed in package: %s", path) + } + + clean := filepath.Clean(path) + if clean == "." { + return "", fmt.Errorf("invalid file path: %s", path) + } + + upPrefix := ".." + string(filepath.Separator) + if clean == ".." || strings.HasPrefix(clean, upPrefix) { + return "", fmt.Errorf("invalid file path: %s", path) + } + + return clean, nil +} + +func safeJoinRoot(rootAbs, relPath string) (string, error) { + if filepath.IsAbs(relPath) { + return "", fmt.Errorf("absolute path not allowed: %s", relPath) + } + + joined := filepath.Clean(filepath.Join(rootAbs, relPath)) + if rootAbs == string(filepath.Separator) { + return joined, nil + } + + rootPrefix := rootAbs + string(filepath.Separator) + if joined != rootAbs && !strings.HasPrefix(joined, rootPrefix) { + return "", fmt.Errorf("path escapes root: %s", relPath) + } + + return joined, nil +} + +func (i *Installer) withDBLock(exclusive bool, fn func() error) error { + if _, err := os.Stat(i.pkgDB); os.IsNotExist(err) { + if !exclusive { + return fn() + } + if err := os.MkdirAll(i.pkgDB, 0o755); err != nil { + return fmt.Errorf("failed to create package database: %w", err) + } + } else if err != nil { return err } - defer sourceFile.Close() - destFile, err := os.Create(dst) + lockPath := filepath.Join(i.pkgDB, ".lock") + lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o644) if err != nil { - return err + return fmt.Errorf("failed to open lock file: %w", err) } - defer destFile.Close() + defer lockFile.Close() - _, err = io.Copy(destFile, sourceFile) - return err + lockType := syscall.LOCK_SH + if exclusive { + lockType = syscall.LOCK_EX + } + if err := syscall.Flock(int(lockFile.Fd()), lockType); err != nil { + return fmt.Errorf("failed to lock package database: %w", err) + } + defer syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + + return fn() } -// GetRootDir returns the root directory -func (i *Installer) GetRootDir() string { - return i.rootDir -} +// GetRootDir returns install root. +func (i *Installer) GetRootDir() string { return i.rootDir } -// GetPkgDB returns the package database directory -func (i *Installer) GetPkgDB() string { - return i.pkgDB -} +// GetPkgDB returns package database directory. +func (i *Installer) GetPkgDB() string { return i.pkgDB } -// IsInstalled checks if a package is installed +// IsInstalled checks whether package is installed. func (i *Installer) IsInstalled(packageName string) bool { - pkgDir := filepath.Join(i.pkgDB, packageName) - _, err := os.Stat(pkgDir) + _, err := os.Stat(filepath.Join(i.pkgDB, packageName)) return err == nil } -// GetInstalledVersion gets the installed version of a package +// GetInstalledVersion returns installed version. func (i *Installer) GetInstalledVersion(packageName string) (string, error) { - pkgInfo, err := i.getPackageInfo(packageName) + pkgInfo, err := i.GetPackageInfo(packageName) if err != nil { return "", err } return pkgInfo.Version, nil } -// GetInstalledFiles gets the list of installed files for a package +// GetInstalledFiles returns installed file list. func (i *Installer) GetInstalledFiles(packageName string) ([]string, error) { - pkgInfo, err := i.getPackageInfo(packageName) + pkgInfo, err := i.GetPackageInfo(packageName) if err != nil { return nil, err } return pkgInfo.Files, nil } -// VerifyPackage verifies that all files of a package exist +// VerifyPackage checks that all package files exist. func (i *Installer) VerifyPackage(packageName string) error { - pkgInfo, err := i.getPackageInfo(packageName) + pkgInfo, err := i.GetPackageInfo(packageName) if err != nil { return err } - for _, file := range pkgInfo.Files { - path := filepath.Join(i.rootDir, file) - if _, err := os.Stat(path); err != nil { - return fmt.Errorf("missing file: %s", file) - } + rootAbs, err := filepath.Abs(filepath.Clean(i.rootDir)) + if err != nil { + return fmt.Errorf("failed to resolve root directory: %w", err) } + for _, file := range pkgInfo.Files { + relPath, err := sanitizePackagePath(file) + if err != nil { + return err + } + path, err := safeJoinRoot(rootAbs, relPath) + if err != nil { + return err + } + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("missing file: %s", relPath) + } + } return nil } -// GetPackageSize gets the total size of installed package files +// GetPackageSize returns total size of installed files. func (i *Installer) GetPackageSize(packageName string) (int64, error) { - pkgInfo, err := i.getPackageInfo(packageName) + pkgInfo, err := i.GetPackageInfo(packageName) if err != nil { return 0, err } + rootAbs, err := filepath.Abs(filepath.Clean(i.rootDir)) + if err != nil { + return 0, fmt.Errorf("failed to resolve root directory: %w", err) + } + var totalSize int64 for _, file := range pkgInfo.Files { - path := filepath.Join(i.rootDir, file) - info, err := os.Stat(path) + relPath, err := sanitizePackagePath(file) + if err != nil { + return 0, err + } + path, err := safeJoinRoot(rootAbs, relPath) + if err != nil { + return 0, err + } + info, err := os.Lstat(path) if err != nil { return 0, err } @@ -402,3 +1164,27 @@ func (i *Installer) GetPackageSize(packageName string) (int64, error) { return totalSize, nil } + +type installCandidate struct { + path string + info *types.PkgInfo +} + +type installTransactionState struct { + pkgInfo *types.PkgInfo + replaced *removeTransactionState + installedPaths []string + newBackups []pathBackup + txDir string +} + +type removeTransactionState struct { + pkgName string + info *types.PkgInfo + backups []pathBackup +} + +type pathBackup struct { + originalPath string + backupPath string +} diff --git a/pkg/installer/installer_test.go b/pkg/installer/installer_test.go new file mode 100644 index 0000000..a67cceb --- /dev/null +++ b/pkg/installer/installer_test.go @@ -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") + } +} diff --git a/pkg/packager/packager.go b/pkg/packager/packager.go index 2e1559f..6e16462 100644 --- a/pkg/packager/packager.go +++ b/pkg/packager/packager.go @@ -1,13 +1,14 @@ package packager import ( - "bufio" + "archive/tar" "fmt" + "io" "os" "path/filepath" - "strings" "time" + "github.com/klauspost/compress/zstd" "github.com/mholt/archiver/v3" "zsvo/pkg/recipe" "zsvo/pkg/types" @@ -74,19 +75,20 @@ func (p *Packager) Package(recipe *recipe.Recipe) error { Name: recipe.Name, Version: recipe.Version, Description: recipe.Description, - Dependencies: recipe.Dependencies, + Dependencies: recipe.Deps, Files: files, InstallDate: time.Now().Format(time.RFC3339), } // Create package info file - pkgInfoFile := filepath.Join(packageDir, ".pkginfo") + pkgInfoFile := filepath.Join(stagingDir, types.PackageMetadataFile) if err := p.writePkgInfo(pkgInfoFile, pkgInfo); err != nil { return fmt.Errorf("failed to write package info to %s: %w", pkgInfoFile, err) } + defer os.Remove(pkgInfoFile) // Create package archive - if err := p.createArchive(stagingDir, packageFile, pkgInfoFile); err != nil { + if err := p.createArchive(stagingDir, packageFile); err != nil { return fmt.Errorf("failed to create package archive %s: %w", packageFile, err) } @@ -123,20 +125,76 @@ func (p *Packager) writePkgInfo(path string, pkgInfo *types.PkgInfo) error { } defer file.Close() - // Write package info in simple format - content := fmt.Sprintf( - "name = %q\nversion = %q\ndescription = %q\ndependencies = %v\nfiles = %v\ninstall_date = %q\n", - pkgInfo.Name, pkgInfo.Version, pkgInfo.Description, pkgInfo.Dependencies, pkgInfo.Files, pkgInfo.InstallDate, - ) - _, err = file.WriteString(content) - return err + return types.WritePkgInfo(file, pkgInfo) } // createArchive creates a compressed archive -func (p *Packager) createArchive(sourceDir, archivePath, pkgInfoFile string) error { - // Create archive using archiver.Archive - paths := []string{sourceDir, pkgInfoFile} - return archiver.Archive(paths, archivePath) +func (p *Packager) createArchive(sourceDir, archivePath string) error { + outFile, err := os.Create(archivePath) + if err != nil { + return err + } + defer outFile.Close() + + zstdWriter, err := zstd.NewWriter(outFile) + if err != nil { + return err + } + defer zstdWriter.Close() + + tarWriter := tar.NewWriter(zstdWriter) + defer tarWriter.Close() + + return filepath.Walk(sourceDir, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == sourceDir { + return nil + } + + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return err + } + relPath = filepath.ToSlash(relPath) + + var linkTarget string + if info.Mode()&os.ModeSymlink != 0 { + linkTarget, err = os.Readlink(path) + if err != nil { + return err + } + } + + header, err := tar.FileInfoHeader(info, linkTarget) + if err != nil { + return err + } + header.Name = relPath + + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + + if !info.Mode().IsRegular() { + return nil + } + + file, err := os.Open(path) + if err != nil { + return err + } + if _, err := io.Copy(tarWriter, file); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + + return nil + }) } // Extract extracts a package archive @@ -164,9 +222,9 @@ func (p *Packager) ReadPkgInfo(archivePath string) (*types.PkgInfo, error) { } // Read package info - pkgInfoFile := filepath.Join(tmpDir, ".pkginfo") + pkgInfoFile := filepath.Join(tmpDir, types.PackageMetadataFile) - // Check if .pkginfo file exists + // Check if metadata file exists. if _, err := os.Stat(pkgInfoFile); os.IsNotExist(err) { return nil, fmt.Errorf("package info file not found in archive") } @@ -182,67 +240,7 @@ func (p *Packager) readPkgInfoFromFile(path string) (*types.PkgInfo, error) { } defer file.Close() - var pkgInfo types.PkgInfo - // Simple parser for our package info format - // In a real implementation, you'd want to use a proper TOML parser - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - // Remove quotes from value - value = strings.Trim(value, "\"") - - switch key { - case "name": - pkgInfo.Name = value - case "version": - pkgInfo.Version = value - case "description": - pkgInfo.Description = value - case "dependencies": - // Parse dependencies array - value = strings.Trim(value, "[]") - if value != "" { - pkgInfo.Dependencies = strings.Split(value, ",") - for i, dep := range pkgInfo.Dependencies { - pkgInfo.Dependencies[i] = strings.TrimSpace(strings.Trim(dep, "\"")) - } - } - case "files": - // Parse files array - value = strings.Trim(value, "[]") - if value != "" { - pkgInfo.Files = strings.Split(value, ",") - for i, file := range pkgInfo.Files { - pkgInfo.Files[i] = strings.TrimSpace(strings.Trim(file, "\"")) - } - } - case "install_date": - pkgInfo.InstallDate = value - } - } - - if err := scanner.Err(); err != nil { - return nil, err - } - - // Validate that we actually parsed some data - if pkgInfo.Name == "" { - return nil, fmt.Errorf("invalid package info: missing name") - } - - return &pkgInfo, nil + return types.ReadPkgInfo(file) } // VerifyPackage verifies package integrity @@ -294,16 +292,3 @@ func (p *Packager) Clean(recipe *recipe.Recipe) error { packageDir := p.GetPackageDir(recipe) return os.RemoveAll(packageDir) } - -// pkgInfoFileInfo implements os.FileInfo for package info -type pkgInfoFileInfo struct { - name string - size int64 -} - -func (f *pkgInfoFileInfo) Name() string { return f.name } -func (f *pkgInfoFileInfo) Size() int64 { return f.size } -func (f *pkgInfoFileInfo) Mode() os.FileMode { return 0644 } -func (f *pkgInfoFileInfo) ModTime() time.Time { return time.Now() } -func (f *pkgInfoFileInfo) IsDir() bool { return false } -func (f *pkgInfoFileInfo) Sys() interface{} { return nil } diff --git a/pkg/packager/packager_test.go b/pkg/packager/packager_test.go new file mode 100644 index 0000000..72e91fd --- /dev/null +++ b/pkg/packager/packager_test.go @@ -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) + } +} diff --git a/pkg/recipe/recipe.go b/pkg/recipe/recipe.go index ee17762..b4d2702 100644 --- a/pkg/recipe/recipe.go +++ b/pkg/recipe/recipe.go @@ -1,110 +1,279 @@ package recipe import ( + "bufio" "fmt" "io" "os" "path/filepath" - - "github.com/BurntSushi/toml" + "strings" ) -// Recipe represents a package recipe +// Recipe represents a package recipe. type Recipe struct { - Name string `toml:"name"` - Version string `toml:"version"` - Description string `toml:"description,omitempty"` - Source Source `toml:"source"` - Build Build `toml:"build"` - Package Package `toml:"package"` - Dependencies []string `toml:"dependencies,omitempty"` - Options map[string]bool `toml:"options,omitempty"` + Name string + Version string + Description string + Source Source + Build []string + Install []string + Deps []string + Dir string } -// Source represents source information +// Source represents source information. type Source struct { - URL string `toml:"url"` - Sha256 string `toml:"sha256"` - Patches []string `toml:"patches,omitempty"` + URL string + Sha256 string + DebianDSC string + Patches []string } -// Build represents build configuration -type Build struct { - Commands []string `toml:"commands"` - Env []string `toml:"env,omitempty"` -} - -// Package represents package configuration -type Package struct { - Commands []string `toml:"commands"` -} - -// ParseRecipe parses a recipe from a TOML file +// ParseRecipe parses a recipe from a YAML file. func ParseRecipe(path string) (*Recipe, error) { - // Validate input path if path == "" { return nil, fmt.Errorf("recipe path cannot be empty") } - // Clean and normalize path path = filepath.Clean(path) - file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("failed to open recipe file %s: %w", path, err) } defer file.Close() - return ParseRecipeFromReader(file) + rcp, err := ParseRecipeFromReader(file) + if err != nil { + return nil, err + } + rcp.Dir = filepath.Dir(path) + return rcp, nil } -// ParseRecipeFromReader parses a recipe from an io.Reader +// ParseRecipeFromReader parses a recipe from an io.Reader. func ParseRecipeFromReader(r io.Reader) (*Recipe, error) { - var recipe Recipe - if _, err := toml.NewDecoder(r).Decode(&recipe); err != nil { - return nil, fmt.Errorf("failed to parse recipe: %w", err) + rcp := &Recipe{} + + scanner := bufio.NewScanner(r) + section := "" + sourceSubsection := "" + lineNo := 0 + + for scanner.Scan() { + lineNo++ + raw := strings.TrimRight(scanner.Text(), "\r") + trimmed := strings.TrimSpace(raw) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + 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") } - if recipe.Version == "" { + if rcp.Version == "" { return nil, fmt.Errorf("recipe must have a version") } - if recipe.Source.URL == "" { - return nil, fmt.Errorf("recipe must have a source URL") + if rcp.Source.URL != "" && rcp.Source.DebianDSC != "" { + return nil, fmt.Errorf("source.url and source.debian_dsc are mutually exclusive") } - if recipe.Source.Sha256 == "" { + + sourceURL := strings.TrimSpace(rcp.Source.URL) + if rcp.Source.DebianDSC != "" { + if !isDebianDSCURL(rcp.Source.DebianDSC) { + return nil, fmt.Errorf("source.debian_dsc must point to a .dsc file") + } + sourceURL = strings.TrimSpace(rcp.Source.DebianDSC) + } + + if sourceURL == "" { + return nil, fmt.Errorf("recipe must have source.url or source.debian_dsc") + } + + // SHA256 is mandatory for direct archives, optional for Debian .dsc flow. + if rcp.Source.Sha256 == "" && !isDebianDSCURL(sourceURL) { return nil, fmt.Errorf("recipe must have a source SHA256 checksum") } - if len(recipe.Build.Commands) == 0 { + if len(rcp.Build) == 0 { return nil, fmt.Errorf("recipe must have build commands") } + 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 { return fmt.Sprintf("%s-%s", r.Name, r.Version) } -// GetPackageFileName returns the package file name +// GetPackageFileName returns the package file name. func (r *Recipe) GetPackageFileName() string { return fmt.Sprintf("%s.pkg.tar.zst", r.GetPackageName()) } -// GetPackageDir returns the package directory path +// GetPackageDir returns the package directory path. func (r *Recipe) GetPackageDir(baseDir string) string { return filepath.Join(baseDir, "packages", r.GetPackageName()) } -// GetSourceDir returns the source directory path +// GetSourceDir returns the source directory path. func (r *Recipe) GetSourceDir(baseDir string) string { return filepath.Join(baseDir, "sources", r.GetPackageName()) } -// GetStagingDir returns the staging directory path +// GetStagingDir returns the staging directory path. func (r *Recipe) GetStagingDir(baseDir string) string { return filepath.Join(baseDir, "staging", r.GetPackageName()) } diff --git a/pkg/recipe/recipe_test.go b/pkg/recipe/recipe_test.go new file mode 100644 index 0000000..d221504 --- /dev/null +++ b/pkg/recipe/recipe_test.go @@ -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") + } +} diff --git a/pkg/types/pkginfo.go b/pkg/types/pkginfo.go index 9690545..b5cd2e1 100644 --- a/pkg/types/pkginfo.go +++ b/pkg/types/pkginfo.go @@ -1,11 +1,13 @@ package types +const PackageMetadataFile = ".zsvo.yml" + // PkgInfo represents package metadata type PkgInfo struct { - Name string `toml:"name"` - Version string `toml:"version"` - Description string `toml:"description,omitempty"` - Dependencies []string `toml:"dependencies,omitempty"` - Files []string `toml:"files"` - InstallDate string `toml:"install_date"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Description string `yaml:"description,omitempty"` + Dependencies []string `yaml:"deps,omitempty"` + Files []string `yaml:"files"` + InstallDate string `yaml:"install_date"` } diff --git a/pkg/types/pkginfo_codec.go b/pkg/types/pkginfo_codec.go new file mode 100644 index 0000000..0049d89 --- /dev/null +++ b/pkg/types/pkginfo_codec.go @@ -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 +} diff --git a/pkg/types/pkginfo_codec_test.go b/pkg/types/pkginfo_codec_test.go new file mode 100644 index 0000000..f3d7715 --- /dev/null +++ b/pkg/types/pkginfo_codec_test.go @@ -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) + } +} diff --git a/recipes/zlib.toml b/recipes/zlib.toml deleted file mode 100644 index 4ffd52d..0000000 --- a/recipes/zlib.toml +++ /dev/null @@ -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" -] - diff --git a/recipes/zlib.yaml b/recipes/zlib.yaml new file mode 100644 index 0000000..1b424dd --- /dev/null +++ b/recipes/zlib.yaml @@ -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 diff --git a/zsvo b/zsvo index 300d574..daf16ce 100755 Binary files a/zsvo and b/zsvo differ