Files
ipsw/internal/commands/ent/replacement.go
2026-03-10 17:08:45 -06:00

553 lines
17 KiB
Go

/*
Copyright © 2018-2026 blacktop
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package ent
import (
"bufio"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"gorm.io/gorm"
)
// VersionComparator represents an iOS version for comparison purposes
type VersionComparator struct {
MajorMinor string // e.g., "26.0", "18.5"
Build string // e.g., "22G87", "beta6", "22F76"
Raw string // Original version string
}
// ReplacementConfig holds configuration for replacement operations
type ReplacementConfig struct {
Strategy string // auto, prompt, force
DryRun bool
}
// ReplacementPlan represents a planned replacement operation
type ReplacementPlan struct {
ToReplace []IPSWInfo
NewIPSW IPSWInfo
Config *ReplacementConfig
ReasonMsg string
ConflictType string
}
// IPSWInfo represents basic information about an IPSW
type IPSWInfo struct {
Version string
BuildID string
ID string // Database ID
Name string // Filename
Platform string // Platform (iOS, macOS, etc.)
}
// ParseVersionComparator creates a VersionComparator from a version string
func ParseVersionComparator(version, build string) VersionComparator {
majorMinor := extractMajorMinorVersion(version)
return VersionComparator{
MajorMinor: majorMinor,
Build: build,
Raw: version,
}
}
// extractMajorMinorVersion extracts major.minor from version string
// Examples: "26.0.1" -> "26.0", "18.5" -> "18.5"
func extractMajorMinorVersion(version string) string {
parts := strings.Split(version, ".")
if len(parts) >= 2 {
return parts[0] + "." + parts[1]
}
return version
}
// ShouldReplace determines if an existing version should be replaced by a new version
func ShouldReplace(existing, new VersionComparator) bool {
// Only replace if same major.minor version
if existing.MajorMinor != new.MajorMinor {
return false
}
// If versions are identical, compare builds
if existing.Raw == new.Raw {
return CompareBuildVersions(existing.Build, new.Build) < 0
}
// Compare full versions (e.g., 26.0.1 vs 26.0.2)
return CompareVersionStrings(existing.Raw, new.Raw) < 0
}
// ShouldReplaceWithPlatform determines if an existing IPSW should be replaced by a new one
// considering both platform and version
func ShouldReplaceWithPlatform(existingIPSW, newIPSW IPSWInfo) bool {
// Only replace if same platform
if existingIPSW.Platform != newIPSW.Platform {
return false
}
// Use existing version comparison logic
existingComparator := ParseVersionComparator(existingIPSW.Version, existingIPSW.BuildID)
newComparator := ParseVersionComparator(newIPSW.Version, newIPSW.BuildID)
return ShouldReplace(existingComparator, newComparator)
}
// CompareBuildVersions compares two build version strings
// Returns: -1 if a < b, 0 if a == b, 1 if a > b
func CompareBuildVersions(a, b string) int {
// Handle beta versions
if strings.Contains(a, "beta") || strings.Contains(b, "beta") {
return compareBetaBuilds(a, b)
}
// Handle numeric build IDs (e.g., 22G87 vs 22G89)
if isNumericBuild(a) && isNumericBuild(b) {
return compareNumericBuilds(a, b)
}
// Fallback to string comparison
if a < b {
return -1
} else if a > b {
return 1
}
return 0
}
// CompareVersionStrings compares two version strings (e.g., "26.0.1" vs "26.0.2")
func CompareVersionStrings(a, b string) int {
aParts := parseVersionParts(a)
bParts := parseVersionParts(b)
maxLen := max(len(bParts), len(aParts))
for i := range maxLen {
aVal := 0
bVal := 0
if i < len(aParts) {
aVal = aParts[i]
}
if i < len(bParts) {
bVal = bParts[i]
}
if aVal < bVal {
return -1
} else if aVal > bVal {
return 1
}
}
return 0
}
// parseVersionParts parses a version string into numeric parts
func parseVersionParts(version string) []int {
parts := strings.Split(version, ".")
result := make([]int, len(parts))
for i, part := range parts {
if val, err := strconv.Atoi(part); err == nil {
result[i] = val
}
}
return result
}
// compareBetaBuilds compares beta build versions
func compareBetaBuilds(a, b string) int {
betaRegex := regexp.MustCompile(`beta(\d+)`)
aMatches := betaRegex.FindStringSubmatch(a)
bMatches := betaRegex.FindStringSubmatch(b)
// If both are beta versions
if len(aMatches) > 1 && len(bMatches) > 1 {
aNum, _ := strconv.Atoi(aMatches[1])
bNum, _ := strconv.Atoi(bMatches[1])
if aNum < bNum {
return -1
} else if aNum > bNum {
return 1
}
return 0
}
// Beta versions are considered "less than" release versions
if len(aMatches) > 1 && len(bMatches) == 0 {
return -1
}
if len(aMatches) == 0 && len(bMatches) > 1 {
return 1
}
// Fallback to string comparison
return strings.Compare(a, b)
}
// isNumericBuild checks if a build ID follows numeric pattern (e.g., 22G87)
func isNumericBuild(build string) bool {
match, _ := regexp.MatchString(`^\d+[A-Z]+\d+$`, build)
return match
}
// compareNumericBuilds compares numeric build IDs
func compareNumericBuilds(a, b string) int {
// Extract prefix numbers and suffix numbers
aPrefix, aSuffix := extractBuildParts(a)
bPrefix, bSuffix := extractBuildParts(b)
// Compare prefixes first
if aPrefix != bPrefix {
if aPrefix < bPrefix {
return -1
}
return 1
}
// Compare suffixes
if aSuffix < bSuffix {
return -1
} else if aSuffix > bSuffix {
return 1
}
return 0
}
// extractBuildParts extracts numeric parts from build ID (e.g., "22G87" -> 22, 87)
func extractBuildParts(build string) (int, int) {
regex := regexp.MustCompile(`^(\d+)[A-Z]+(\d+)$`)
matches := regex.FindStringSubmatch(build)
if len(matches) < 3 {
return 0, 0
}
prefix, _ := strconv.Atoi(matches[1])
suffix, _ := strconv.Atoi(matches[2])
return prefix, suffix
}
// CreateReplacementPlan analyzes what would be replaced and creates a plan
func CreateReplacementPlan(newIPSW IPSWInfo, existingIPSWs []IPSWInfo, config *ReplacementConfig) *ReplacementPlan {
var toReplace []IPSWInfo
var reasonMsg string
for _, existing := range existingIPSWs {
if ShouldReplaceWithPlatform(existing, newIPSW) {
toReplace = append(toReplace, existing)
}
}
if len(toReplace) > 0 {
if len(toReplace) == 1 {
reasonMsg = fmt.Sprintf("Replacing %s %s (build %s) with %s %s (build %s)",
toReplace[0].Platform, toReplace[0].Version, toReplace[0].BuildID,
newIPSW.Platform, newIPSW.Version, newIPSW.BuildID)
} else {
newComparator := ParseVersionComparator(newIPSW.Version, newIPSW.BuildID)
reasonMsg = fmt.Sprintf("Replacing %d older builds of %s %s with newer build %s",
len(toReplace), newIPSW.Platform, newComparator.MajorMinor, newIPSW.BuildID)
}
} else {
newComparator := ParseVersionComparator(newIPSW.Version, newIPSW.BuildID)
reasonMsg = fmt.Sprintf("No older builds found for %s %s - will add as new entry", newIPSW.Platform, newComparator.MajorMinor)
}
return &ReplacementPlan{
ToReplace: toReplace,
NewIPSW: newIPSW,
Config: config,
ReasonMsg: reasonMsg,
ConflictType: determineConflictType(toReplace, newIPSW),
}
}
// determineConflictType categorizes the type of replacement
func determineConflictType(toReplace []IPSWInfo, newIPSW IPSWInfo) string {
if len(toReplace) == 0 {
return "new"
}
if len(toReplace) == 1 {
existing := toReplace[0]
if existing.Version == newIPSW.Version {
return "build_update"
}
return "version_update"
}
return "multiple_replace"
}
// ReplacementStrategy interface for different replacement implementations
type ReplacementStrategy interface {
SupportsVersionBasedReplacement() bool
CreateReplacementPlan(newIPSW IPSWInfo, existingIPSWs []IPSWInfo, config *ReplacementConfig) (*ReplacementPlan, error)
ExecuteReplacement(plan *ReplacementPlan) error
GetExistingIPSWs(platform, version string) ([]IPSWInfo, error)
}
// SQLiteReplacementStrategy handles replacements for SQLite databases
type SQLiteReplacementStrategy struct {
service *DatabaseService
}
// NewSQLiteReplacementStrategy creates a new SQLite replacement strategy
func NewSQLiteReplacementStrategy(service *DatabaseService) *SQLiteReplacementStrategy {
return &SQLiteReplacementStrategy{service: service}
}
// SupportsVersionBasedReplacement returns true for SQLite strategy
func (s *SQLiteReplacementStrategy) SupportsVersionBasedReplacement() bool {
return true
}
// CreateReplacementPlan creates a replacement plan for SQLite
func (s *SQLiteReplacementStrategy) CreateReplacementPlan(newIPSW IPSWInfo, existingIPSWs []IPSWInfo, config *ReplacementConfig) (*ReplacementPlan, error) {
return CreateReplacementPlan(newIPSW, existingIPSWs, config), nil
}
// ExecuteReplacement executes the replacement plan atomically
func (s *SQLiteReplacementStrategy) ExecuteReplacement(plan *ReplacementPlan) error {
if s.service.gormDB == nil {
return fmt.Errorf("GORM database required for replacement operations")
}
// If dry run, just log what would happen
if plan.Config.DryRun {
fmt.Printf("DRY RUN: %s\n", plan.ReasonMsg)
return nil
}
// Prompt user if strategy is prompt
if plan.Config.Strategy == "prompt" && len(plan.ToReplace) > 0 {
if !promptUserForReplacement(plan) {
fmt.Printf("Replacement cancelled by user\n")
return nil
}
}
// Execute atomic replacement transaction
return s.service.gormDB.Transaction(func(tx *gorm.DB) error {
// Step 1: Delete old IPSW data
for _, oldIPSW := range plan.ToReplace {
if err := s.deleteIPSWData(tx, oldIPSW.ID); err != nil {
return fmt.Errorf("failed to delete old IPSW %s: %w", oldIPSW.ID, err)
}
}
fmt.Printf("✓ %s\n", plan.ReasonMsg)
return nil
})
}
// GetExistingIPSWs retrieves existing IPSWs for version comparison
func (s *SQLiteReplacementStrategy) GetExistingIPSWs(platform, version string) ([]IPSWInfo, error) {
if s.service.gormDB == nil {
return nil, fmt.Errorf("GORM database required")
}
majorMinor := extractMajorMinorVersion(version)
// Query IPSWs with same platform and major.minor version
var ipsws []struct {
ID string
Name string
Version string
BuildID string
Platform string
}
err := s.service.gormDB.Table("ipsws").
Select("id, name, version, buildid as build_id, platform").
Where("platform = ? AND version LIKE ?", platform, majorMinor+"%").
Find(&ipsws).Error
if err != nil {
return nil, fmt.Errorf("failed to query existing IPSWs: %w", err)
}
result := make([]IPSWInfo, len(ipsws))
for i, ipsw := range ipsws {
result[i] = IPSWInfo{
ID: ipsw.ID,
Name: ipsw.Name,
Version: ipsw.Version,
BuildID: ipsw.BuildID,
Platform: ipsw.Platform,
}
}
return result, nil
}
// deleteIPSWData deletes all data associated with an IPSW
func (s *SQLiteReplacementStrategy) deleteIPSWData(tx *gorm.DB, ipswID string) error {
// Delete entitlements (cascading delete handles references)
if err := tx.Exec("DELETE FROM entitlements WHERE ipsw_id = ?", ipswID).Error; err != nil {
return fmt.Errorf("failed to delete entitlements: %w", err)
}
// Delete IPSW record
if err := tx.Exec("DELETE FROM ipsws WHERE id = ?", ipswID).Error; err != nil {
return fmt.Errorf("failed to delete IPSW record: %w", err)
}
// Clean up orphaned references
return s.cleanupOrphanedReferences(tx)
}
// cleanupOrphanedReferences removes unused paths, keys, and values
func (s *SQLiteReplacementStrategy) cleanupOrphanedReferences(tx *gorm.DB) error {
// Remove paths not referenced by any entitlements
if err := tx.Exec(`
DELETE FROM paths
WHERE id NOT IN (SELECT DISTINCT path_id FROM entitlements WHERE path_id IS NOT NULL)
`).Error; err != nil {
return fmt.Errorf("failed to cleanup orphaned paths: %w", err)
}
// Remove keys not referenced by any entitlements
if err := tx.Exec(`
DELETE FROM entitlement_keys
WHERE id NOT IN (SELECT DISTINCT key_id FROM entitlements WHERE key_id IS NOT NULL)
`).Error; err != nil {
return fmt.Errorf("failed to cleanup orphaned keys: %w", err)
}
// Remove values not referenced by any entitlements
if err := tx.Exec(`
DELETE FROM entitlement_values
WHERE id NOT IN (SELECT DISTINCT value_id FROM entitlements WHERE value_id IS NOT NULL)
`).Error; err != nil {
return fmt.Errorf("failed to cleanup orphaned values: %w", err)
}
return nil
}
// PostgreSQLReplacementStrategy handles replacements for PostgreSQL databases
type PostgreSQLReplacementStrategy struct {
service *DatabaseService
}
// NewPostgreSQLReplacementStrategy creates a new PostgreSQL replacement strategy
func NewPostgreSQLReplacementStrategy(service *DatabaseService) *PostgreSQLReplacementStrategy {
return &PostgreSQLReplacementStrategy{service: service}
}
// SupportsVersionBasedReplacement returns true for PostgreSQL strategy
func (p *PostgreSQLReplacementStrategy) SupportsVersionBasedReplacement() bool {
return true
}
// CreateReplacementPlan creates a replacement plan for PostgreSQL
func (p *PostgreSQLReplacementStrategy) CreateReplacementPlan(newIPSW IPSWInfo, existingIPSWs []IPSWInfo, config *ReplacementConfig) (*ReplacementPlan, error) {
return CreateReplacementPlan(newIPSW, existingIPSWs, config), nil
}
// ExecuteReplacement executes the replacement plan atomically
func (p *PostgreSQLReplacementStrategy) ExecuteReplacement(plan *ReplacementPlan) error {
// Same implementation as SQLite for now since both use GORM
sqliteStrategy := &SQLiteReplacementStrategy{service: p.service}
return sqliteStrategy.ExecuteReplacement(plan)
}
// GetExistingIPSWs retrieves existing IPSWs for version comparison
func (p *PostgreSQLReplacementStrategy) GetExistingIPSWs(platform, version string) ([]IPSWInfo, error) {
// Same implementation as SQLite for now since both use GORM
sqliteStrategy := &SQLiteReplacementStrategy{service: p.service}
return sqliteStrategy.GetExistingIPSWs(platform, version)
}
// LegacyReplacementStrategy handles replacements for legacy database formats
type LegacyReplacementStrategy struct{}
// NewLegacyReplacementStrategy creates a new legacy replacement strategy
func NewLegacyReplacementStrategy() *LegacyReplacementStrategy {
return &LegacyReplacementStrategy{}
}
// SupportsVersionBasedReplacement returns false for legacy strategy (simple overwrite)
func (l *LegacyReplacementStrategy) SupportsVersionBasedReplacement() bool {
return false
}
// CreateReplacementPlan creates a simple replacement plan for legacy format
func (l *LegacyReplacementStrategy) CreateReplacementPlan(newIPSW IPSWInfo, existingIPSWs []IPSWInfo, config *ReplacementConfig) (*ReplacementPlan, error) {
return &ReplacementPlan{
ToReplace: existingIPSWs, // Replace all existing data
NewIPSW: newIPSW,
Config: config,
ReasonMsg: "Legacy format: replacing entire database with new data",
ConflictType: "legacy_overwrite",
}, nil
}
// ExecuteReplacement executes simple overwrite for legacy format
func (l *LegacyReplacementStrategy) ExecuteReplacement(plan *ReplacementPlan) error {
if plan.Config.DryRun {
fmt.Printf("DRY RUN: %s\n", plan.ReasonMsg)
return nil
}
fmt.Printf("✓ %s\n", plan.ReasonMsg)
// For legacy format, the actual overwrite is handled by the calling code
return nil
}
// GetExistingIPSWs returns empty list for legacy format
func (l *LegacyReplacementStrategy) GetExistingIPSWs(platform, version string) ([]IPSWInfo, error) {
return []IPSWInfo{}, nil
}
// promptUserForReplacement prompts the user to confirm replacement
func promptUserForReplacement(plan *ReplacementPlan) bool {
fmt.Printf("\n%s\n", plan.ReasonMsg)
if len(plan.ToReplace) > 0 {
fmt.Printf("This will delete the following existing data:\n")
for _, ipsw := range plan.ToReplace {
fmt.Printf(" - %s (iOS %s, build %s)\n", ipsw.Name, ipsw.Version, ipsw.BuildID)
}
}
fmt.Printf("\nDo you want to continue? (y/N): ")
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
response := strings.ToLower(strings.TrimSpace(scanner.Text()))
return response == "y" || response == "yes"
}
return false
}