package installer import ( "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "github.com/j3ssie/osmedeus/v5/internal/core" "github.com/j3ssie/osmedeus/v5/internal/logger" "github.com/j3ssie/osmedeus/v5/internal/terminal" "github.com/j3ssie/osmedeus/v5/public" "go.uber.org/zap" ) // DefaultRegistryURL is the default URL for the binary registry const DefaultRegistryURL = "https://raw.githubusercontent.com/osmedeus/osmedeus-base/main/registry-metadata.json" // BinaryEntry represents a single binary's download/install information // Supports both download URLs and commands per OS/architecture type BinaryEntry struct { Desc string `json:"desc,omitempty"` RepoLink string `json:"repo_link,omitempty"` Version string `json:"version,omitempty"` PackageManager string `json:"package-manager,omitempty"` Tags []string `json:"tags,omitempty"` ValidateCommand string `json:"valide-command,omitempty"` NixPackage string `json:"nix_package,omitempty"` GoInstall string `json:"go_install,omitempty"` // Go package path for go install (e.g., "github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest") Linux map[string]string `json:"linux,omitempty"` Darwin map[string]string `json:"darwin,omitempty"` Windows map[string]string `json:"windows,omitempty"` CommandLinux map[string]string `json:"command-linux,omitempty"` CommandDarwin map[string]string `json:"command-darwin,omitempty"` CommandDual map[string]string `json:"command-dual,omitempty"` MultiCommandsLinux []string `json:"multi-commands-linux,omitempty"` MultiCommandsDarwin []string `json:"multi-commands-darwin,omitempty"` } // BinaryRegistry is a map of binary name to BinaryEntry type BinaryRegistry map[string]BinaryEntry // IsGoGetterPackage returns true if entry uses go-getter package manager func (entry *BinaryEntry) IsGoGetterPackage() bool { return entry.PackageManager == "go-getter" } // GetGoGetterPackagePath returns the Go package path for go-getter entries func (entry *BinaryEntry) GetGoGetterPackagePath() string { if !entry.IsGoGetterPackage() { return "" } if entry.CommandDual != nil { if pkg, ok := entry.CommandDual["dual"]; ok { return pkg } } return "" } // LoadRegistry loads a binary registry from a file path or URL // If no path is provided, uses embedded registry (falls back to GitHub URL if embedded fails) // Optional customHeaders map adds custom HTTP headers for URL fetches func LoadRegistry(pathOrURL string, customHeaders map[string]string) (BinaryRegistry, error) { var data []byte var err error if pathOrURL == "" { // Try embedded registry first data, err = public.GetRegistryMetadata() if err != nil { // Fall back to remote URL data, err = fetchURL(DefaultRegistryURL, customHeaders) } } else if IsURL(pathOrURL) { data, err = fetchURL(pathOrURL, customHeaders) } else { data, err = os.ReadFile(pathOrURL) } if err != nil { return nil, fmt.Errorf("failed to load registry: %w", err) } // Parse into raw map first to filter metadata keys (prefixed with _) var rawRegistry map[string]json.RawMessage if err := json.Unmarshal(data, &rawRegistry); err != nil { return nil, fmt.Errorf("failed to parse registry JSON: %w", err) } // Filter and parse binary entries registry := make(BinaryRegistry) for name, rawEntry := range rawRegistry { // Skip metadata keys (start with underscore) if strings.HasPrefix(name, "_") { continue } var entry BinaryEntry if err := json.Unmarshal(rawEntry, &entry); err != nil { return nil, fmt.Errorf("failed to parse entry '%s': %w", name, err) } registry[name] = entry } return registry, nil } func fetchURL(url string, customHeaders map[string]string) ([]byte, error) { client := &http.Client{ Timeout: 30 * time.Second, } // Create request with User-Agent header req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", core.DefaultUA) // Auto-inject GitHub token for GitHub URLs (helps with rate limiting and private repos) if isGitHubURL(url) { if token := getGitHubToken(); token != "" { req.Header.Set("Authorization", "Bearer "+token) } } // Add custom headers (can override auto-injected headers if needed) for key, value := range customHeaders { req.Header.Set(key, value) } resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP status %d", resp.StatusCode) } return io.ReadAll(resp.Body) } // DetectPackageManager returns the system's package manager func DetectPackageManager() string { if runtime.GOOS == "darwin" { return "brew" } // Linux: check common package managers in order of preference managers := []string{"apt", "dnf", "yum", "pacman", "zypper", "apk"} for _, mgr := range managers { if _, err := exec.LookPath(mgr); err == nil { return mgr } } return "apt" // fallback } // SubstitutePackageManager replaces placeholder func SubstitutePackageManager(command string) string { if !strings.Contains(command, "") { return command } return strings.ReplaceAll(command, "", DetectPackageManager()) } // GetBinaryInfo returns download URL or command(s) for current OS/arch // Returns: url, commands (slice), error // Priority: multi-commands > command-dual > command-.dual > .dual > command-. > . func (entry *BinaryEntry) GetBinaryInfo() (url string, commands []string, err error) { osName := runtime.GOOS arch := runtime.GOARCH // 1. Check multi-commands first var multiCmds []string switch osName { case "linux": multiCmds = entry.MultiCommandsLinux case "darwin": multiCmds = entry.MultiCommandsDarwin } if len(multiCmds) > 0 { // Substitute package manager in all commands result := make([]string, len(multiCmds)) for i, cmd := range multiCmds { result[i] = SubstitutePackageManager(cmd) } return "", result, nil } // 2. Check command-dual (applies to any OS) if entry.CommandDual != nil { if cmd, ok := entry.CommandDual["dual"]; ok { return "", []string{SubstitutePackageManager(cmd)}, nil } } // 3. Check command- with dual key or arch-specific var commandMap map[string]string switch osName { case "linux": commandMap = entry.CommandLinux case "darwin": commandMap = entry.CommandDarwin } if commandMap != nil { // Check dual key first (any architecture) if cmd, ok := commandMap["dual"]; ok { return "", []string{SubstitutePackageManager(cmd)}, nil } // Then check arch-specific if cmd, ok := commandMap[arch]; ok { return "", []string{SubstitutePackageManager(cmd)}, nil } } // 4. Check OS map for dual key (command) or arch-specific URL var urlMap map[string]string switch osName { case "linux": urlMap = entry.Linux case "darwin": urlMap = entry.Darwin case "windows": urlMap = entry.Windows } if urlMap != nil { // Check dual key first (it's a command, not URL) if cmd, ok := urlMap["dual"]; ok { return "", []string{SubstitutePackageManager(cmd)}, nil } // Then check arch-specific URL if downloadURL, ok := urlMap[arch]; ok { return downloadURL, nil, nil } } return "", nil, fmt.Errorf("no download/command available for %s/%s", osName, arch) } // coreUnixTools lists system utilities that should never be copied to external-binaries. // These are typically provided by the OS and copying them is unnecessary. var coreUnixTools = map[string]bool{ // Core utilities "cat": true, "cp": true, "mv": true, "rm": true, "ls": true, "mkdir": true, "rmdir": true, "touch": true, "chmod": true, "chown": true, // Text processing "grep": true, "sed": true, "awk": true, "sort": true, "uniq": true, "head": true, "tail": true, "cut": true, "tr": true, "wc": true, // Network "curl": true, "wget": true, "ping": true, "ssh": true, "scp": true, // Build tools "make": true, "gcc": true, "cc": true, "clang": true, // Other common tools "find": true, "xargs": true, "tar": true, "gzip": true, "gunzip": true, "zip": true, "unzip": true, "diff": true, "patch": true, "which": true, "whoami": true, "hostname": true, "date": true, "env": true, "echo": true, "printf": true, "tee": true, "bash": true, "sh": true, "zsh": true, // Version control "git": true, } // IsCoreUnixTool returns true if the binary name is a core Unix tool func IsCoreUnixTool(name string) bool { return coreUnixTools[name] } // IsBinaryInPath checks if a binary exists and is executable in $PATH func IsBinaryInPath(name string) bool { path, err := exec.LookPath(name) if err != nil { return false } // Verify it's actually executable info, err := os.Stat(path) if err != nil { return false } return info.Mode()&0111 != 0 } // IsBinaryInstalled checks if a binary is installed using validate command or PATH lookup // If entry has a ValidateCommand, run it and check exit code (0 = installed) // If ValidateCommand is empty, fall back to checking if binary name is in PATH func IsBinaryInstalled(name string, entry *BinaryEntry) bool { // If validate command is provided and not empty, use it if entry != nil && entry.ValidateCommand != "" { vc := entry.ValidateCommand // If validate command is a bare binary name (no spaces, flags, or shell operators), // check PATH instead of executing — many tools (e.g. massdns) exit non-zero // when run without arguments, causing false negatives. if !strings.ContainsAny(vc, " \t|;&") { return IsBinaryInPath(vc) } // @NOTE: This is intentional - ValidateCommand comes from the binary registry // configuration which is a trusted source for installation validation commands. cmd := exec.Command("sh", "-c", vc) err := cmd.Run() return err == nil // exit code 0 means installed } // Fall back to default PATH check if IsBinaryInPath(name) { return true } // nuclei-templates is a directory cloned to $HOME/nuclei-templates, not a binary. // Check if the folder exists and is non-empty as a fallback. if name == "nuclei-templates" { if home, err := os.UserHomeDir(); err == nil { dir := filepath.Join(home, "nuclei-templates") entries, err := os.ReadDir(dir) if err == nil && len(entries) > 0 { return true } } } return false } // InstallBinary installs a single binary from the registry // Skips installation if the binary is already available in PATH // Optional customHeaders map adds custom HTTP headers for downloads func InstallBinary(name string, registry BinaryRegistry, binariesFolder string, customHeaders map[string]string) error { // Check if binary already exists in PATH if IsBinaryInPath(name) { if IsCoreUnixTool(name) { // Core Unix tools: show exists, don't copy fmt.Printf("[%s] Binary '%s' already available in PATH (system tool, skipping copy)\n", terminal.Gray(terminal.SymbolBowtie), terminal.HiBlue(name)) return nil } // Non-core tools: show exists, still symlink to external-binaries fmt.Printf("[%s] Binary '%s' already available in PATH, symlinking to external-binaries\n", terminal.Gray(terminal.SymbolBowtie), terminal.HiBlue(name)) entry, ok := registry[name] if ok { _ = SymlinkInstalledBinaryToFolder(name, &entry, binariesFolder) } return nil } entry, ok := registry[name] if !ok { return fmt.Errorf("binary '%s' not found in registry", name) } // Check for go-getter package manager if entry.IsGoGetterPackage() { pkg := entry.GetGoGetterPackagePath() if pkg == "" { return fmt.Errorf("go-getter entry '%s' missing package path in command-dual", name) } // Check if package path contains space-separated destination // Format: "github.com/repo.git?ref=main&depth=1 $HOME/destination" if strings.Contains(pkg, " ") { parts := strings.SplitN(pkg, " ", 2) src := strings.TrimSpace(parts[0]) dest := strings.TrimSpace(parts[1]) // Expand environment variables and tilde in destination dest = ExpandPath(dest) logger.Get().Info("Installing via go-getter", zap.String("name", name), zap.String("src", src), zap.String("dest", dest)) return GetViaGoGetter(src, dest) } // No destination specified - use go-getter to build Go binary logger.Get().Info("Installing binary via go-getter", zap.String("name", name), zap.String("package", pkg)) return InstallBinaryViaGoGetter(name, pkg, binariesFolder) } url, commands, err := entry.GetBinaryInfo() if err != nil { return err } // Execute commands if present if len(commands) > 0 { logger.Get().Info("Installing binary via command(s)", zap.String("name", name), zap.Int("command_count", len(commands))) if err := executeCommands(commands); err != nil { return err } // After successful command execution, symlink the binary to external-binaries if err := SymlinkInstalledBinaryToFolder(name, &entry, binariesFolder); err != nil { logger.Get().Warn("Failed to symlink binary to external-binaries folder", zap.String("name", name), zap.Error(err)) // Don't return error - installation succeeded, this is just an optimization } return nil } logger.Get().Info("Downloading binary", zap.String("name", name), zap.String("url", url)) logger.Get().Info("Installing to", zap.String("path", binariesFolder)) return downloadAndExtractBinary(name, url, binariesFolder, customHeaders) } // executeCommand runs a shell command for installing a binary func executeCommand(command string) error { var cmd *exec.Cmd // @NOTE: This is intentional - installation commands come from the binary registry // configuration which is a trusted source for binary installation procedures. if runtime.GOOS == "windows" { cmd = exec.Command("cmd", "/C", command) } else { cmd = exec.Command("sh", "-c", command) } if os.Getenv("OSMEDEUS_SILENT") == "1" { cmd.Stdout = io.Discard cmd.Stderr = io.Discard } else { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } if err := cmd.Run(); err != nil { return fmt.Errorf("command failed: %w", err) } return nil } // executeCommands runs multiple shell commands sequentially func executeCommands(commands []string) error { for i, command := range commands { logger.Get().Info("Running command", zap.Int("step", i+1), zap.Int("total", len(commands)), zap.String("command", command)) if err := executeCommand(command); err != nil { return fmt.Errorf("command %d failed: %w", i+1, err) } } return nil } // downloadAndExtractBinary downloads and extracts a binary to the binaries folder // Optional customHeaders map adds custom HTTP headers for the download func downloadAndExtractBinary(name, url, binariesFolder string, customHeaders map[string]string) error { // Ensure binaries folder exists if err := os.MkdirAll(binariesFolder, 0755); err != nil { return fmt.Errorf("failed to create binaries folder: %w", err) } // Create temp directory for download tempDir, err := os.MkdirTemp("", "osmedeus-binary-*") if err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } defer func() { _ = os.RemoveAll(tempDir) }() // Determine filename from URL urlParts := strings.Split(url, "/") filename := urlParts[len(urlParts)-1] downloadPath := filepath.Join(tempDir, filename) // Download the file if err := DownloadFile(url, downloadPath, customHeaders); err != nil { return err } // Extract based on archive type archiveType := DetectArchiveType(filename) extractDir := filepath.Join(tempDir, "extracted") logger.Get().Info("Archive type detected", zap.String("type", archiveType)) switch archiveType { case "zip": logger.Get().Info("Extracting zip", zap.String("dest", extractDir)) if err := ExtractZip(downloadPath, extractDir); err != nil { return err } case "tar.gz": logger.Get().Info("Extracting tar.gz", zap.String("dest", extractDir)) if err := ExtractTarGz(downloadPath, extractDir); err != nil { return err } case "gz": // Single file gz - extract directly to binaries folder destPath := filepath.Join(binariesFolder, name) logger.Get().Info("Extracting gz", zap.String("dest", destPath)) if err := ExtractGz(downloadPath, destPath); err != nil { return err } return nil default: // Assume it's a raw binary destPath := filepath.Join(binariesFolder, name) logger.Get().Info("Copying raw binary", zap.String("dest", destPath)) if err := copyFile(downloadPath, destPath); err != nil { return err } if err := os.Chmod(destPath, 0755); err != nil { return err } return nil } // Find and copy the binary from extracted directory return copyBinaryFromExtracted(name, extractDir, binariesFolder) } // copyBinaryFromExtracted finds the binary in extracted directory and copies it func copyBinaryFromExtracted(name, extractDir, binariesFolder string) error { // Look for the binary file var binaryPath string err := filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } baseName := filepath.Base(path) // Match exact name or name with extension if baseName == name || strings.TrimSuffix(baseName, filepath.Ext(baseName)) == name { // Check if it's executable (on Unix) or ends with .exe (Windows) if info.Mode()&0111 != 0 || strings.HasSuffix(baseName, ".exe") || !strings.Contains(baseName, ".") { binaryPath = path return filepath.SkipAll } } return nil }) if err != nil && err != filepath.SkipAll { return fmt.Errorf("failed to search extracted directory: %w", err) } if binaryPath == "" { // If not found by name, look for any executable err := filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } // Skip common non-binary files ext := strings.ToLower(filepath.Ext(path)) if ext == ".md" || ext == ".txt" || ext == ".json" || ext == ".yaml" || ext == ".yml" { return nil } if info.Mode()&0111 != 0 { binaryPath = path return filepath.SkipAll } return nil }) if err != nil && err != filepath.SkipAll { return err } } if binaryPath == "" { return fmt.Errorf("binary '%s' not found in extracted archive", name) } destPath := filepath.Join(binariesFolder, name) if err := copyFile(binaryPath, destPath); err != nil { return err } return os.Chmod(destPath, 0755) } func copyFile(src, dest string) error { // Get source file info to preserve permissions srcInfo, err := os.Stat(src) if err != nil { return err } sourceFile, err := os.Open(src) if err != nil { return err } defer func() { _ = sourceFile.Close() }() destFile, err := os.Create(dest) if err != nil { return err } defer func() { _ = destFile.Close() }() _, err = io.Copy(destFile, sourceFile) if err != nil { return err } // Preserve original file permissions return os.Chmod(dest, srcInfo.Mode()) } // symlinkOrCopyFile creates a symlink from src to dest, falling back to copy if symlink fails func symlinkOrCopyFile(src, dest string) error { // Remove existing file/symlink at dest if _, err := os.Lstat(dest); err == nil { if err := os.Remove(dest); err != nil { return fmt.Errorf("failed to remove existing destination: %w", err) } } // Try symlink first, fall back to copy if err := os.Symlink(src, dest); err != nil { logger.Get().Warn("Symlink failed, falling back to copy", zap.String("src", src), zap.String("dest", dest), zap.Error(err)) return copyFile(src, dest) } return nil } // SymlinkInstalledBinaryToFolder finds a binary using LookPath and symlinks it to the destination folder // Uses the validate command (valide-command) from registry to locate the binary // Returns nil if binary not found (installation may have failed) or symlink succeeds func SymlinkInstalledBinaryToFolder(name string, entry *BinaryEntry, destFolder string) error { // Determine which command to look for lookupCmd := name if entry != nil && entry.ValidateCommand != "" { lookupCmd = entry.ValidateCommand } // Find the binary path using LookPath (equivalent to 'which') binaryPath, err := exec.LookPath(lookupCmd) if err != nil { logger.Get().Warn("Could not find installed binary in PATH", zap.String("name", name), zap.String("lookup", lookupCmd), zap.Error(err)) return nil // Not an error - binary might not be in PATH yet } // Ensure destination folder exists if err := os.MkdirAll(destFolder, 0755); err != nil { return fmt.Errorf("failed to create destination folder: %w", err) } // Copy to destination destPath := filepath.Join(destFolder, name) // Skip if source and dest are the same if binaryPath == destPath { logger.Get().Debug("Binary already in destination folder", zap.String("path", destPath)) return nil } logger.Get().Info("Symlinking binary to external-binaries", zap.String("from", binaryPath), zap.String("to", destPath)) if err := symlinkOrCopyFile(binaryPath, destPath); err != nil { return fmt.Errorf("failed to symlink binary: %w", err) } return nil } // ListBinaries returns all binary names in the registry func (r BinaryRegistry) ListBinaries() []string { names := make([]string, 0, len(r)) for name := range r { names = append(names, name) } return names }