Files
ipsw/internal/diff/localizations.go
blacktop e209828c31 feat(diff): add cacheable task-based reports
Refactor diff jobs into task-owned renderers with persistent cache support, root-confined walkers, and quieter Mach-O/Firmware output.
2026-06-04 12:02:46 -06:00

383 lines
9.3 KiB
Go

package diff
import (
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"github.com/apex/log"
"github.com/blacktop/go-plist"
"github.com/blacktop/ipsw/internal/utils"
)
var localizationOwnerExtensions = [...]string{
".assistantbundle",
".syncbundle",
".axuiservice",
".axbundle",
".framework",
".migrator",
".imservice",
".appex",
".bundle",
".driver",
".plugin",
".dext",
".kext",
".app",
".xpc",
".fs",
}
// parseLocalizations handles OTA and Directory input modes. IPSW mode is
// handled by locsJob via the volume-major orchestrator.
func (d *Diff) parseLocalizations() error {
var oldMounts, newMounts map[string]mount
switch d.Old.InputMode {
case inputModeOTA:
if err := d.ensureOTAPayloadFilesystems(); err != nil {
return err
}
oldMounts = otaDiffMounts(&d.Old)
newMounts = otaDiffMounts(&d.New)
case inputModeDirectory:
oldMounts = d.Old.Mount
newMounts = d.New.Mount
default:
return fmt.Errorf("diff: parseLocalizations: IPSW mode uses locsJob via the volume-major orchestrator")
}
prevByVolume := make(map[string]map[string]string)
nextByVolume := make(map[string]map[string]string)
names := unionMountNames(oldMounts, newMounts)
for _, name := range names {
if mnt, ok := oldMounts[name]; ok {
prevByVolume[name] = make(map[string]string)
if err := collectLocalizedResourcesFromMount(mnt.MountPath, name, prevByVolume[name]); err != nil {
return err
}
}
if mnt, ok := newMounts[name]; ok {
nextByVolume[name] = make(map[string]string)
if err := collectLocalizedResourcesFromMount(mnt.MountPath, name, nextByVolume[name]); err != nil {
return err
}
}
}
out, err := assembleLocalizationDiffByVolume(names, prevByVolume, nextByVolume)
if err != nil {
return err
}
d.Localizations = out
return nil
}
// assembleLocalizationDiffByVolume produces the per-volume PlistDiff map
// for localizations. Empty per-volume diffs are omitted. Shared by
// parseLocalizations (OTA/Directory) and locsJob (IPSW).
func assembleLocalizationDiffByVolume(volumes []string, prev, next map[string]map[string]string) (map[string]*PlistDiff, error) {
out := make(map[string]*PlistDiff)
for _, vol := range volumes {
diff := &PlistDiff{
New: make(map[string]string),
Updated: make(map[string]string),
}
if err := diffLocalizedResources(diff, prev[vol], next[vol]); err != nil {
return nil, err
}
if plistDiffHasContent(diff) {
out[vol] = diff
}
}
return out, nil
}
// collectLocalizedResourcesFromMount walks a single mount root for
// localization resources, populating out keyed by the resource path.
func collectLocalizedResourcesFromMount(mountPath, volumeLabel string, out map[string]string) error {
return walkFolderFiles(mountPath, func(_ string, absPath string) error {
return collectLocalizedResourceFile(out, volumeLabel, mountPath, absPath)
})
}
func collectLocalizedResourceFile(out map[string]string, mountName, mountPoint, absPath string) error {
if !isLocalizedResourcePath(absPath) {
return nil
}
if !isUSEnglishLocalizedResourcePath(absPath) {
return nil
}
content, err := normalizeLocalizedResource(absPath)
if err != nil {
log.Debugf("skipping localization resource %s: %v", absPath, err)
return nil
}
if content == "" {
return nil
}
relPath := relativeToRoot(mountPoint, absPath)
key := filepath.ToSlash(filepath.Join(mountName, relPath))
out[key] = content
return nil
}
func isLocalizedResourcePath(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".loctable", ".strings", ".stringsdict":
return true
default:
return false
}
}
func normalizeLocalizedResource(path string) (string, error) {
data, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return "", fmt.Errorf("failed to read resource: %w", err)
}
var decoded map[string]any
if _, err := plist.Unmarshal(data, &decoded); err != nil {
return "", fmt.Errorf("failed to decode plist resource: %w", err)
}
if strings.EqualFold(filepath.Ext(path), ".loctable") {
decoded = filterUSEnglishLocalizationTable(decoded)
if len(decoded) == 0 {
return "", nil
}
}
var lines []string
flattenLocalizedValue("", decoded, &lines)
slices.Sort(lines)
return strings.Join(lines, "\n"), nil
}
func isUSEnglishLocalizedResourcePath(path string) bool {
locale, ok := lprojLocale(path)
if !ok {
return true
}
return isUSEnglishLproj(locale)
}
func lprojLocale(path string) (string, bool) {
for part := range strings.SplitSeq(filepath.ToSlash(path), "/") {
lower := strings.ToLower(part)
if strings.HasSuffix(lower, ".lproj") {
return part[:len(part)-len(".lproj")], true
}
}
return "", false
}
func isUSEnglishLproj(locale string) bool {
if strings.EqualFold(locale, "base") {
return true
}
return isUSEnglishLocale(locale)
}
func filterUSEnglishLocalizationTable(values map[string]any) map[string]any {
if locale, ok := preferredEnglishLocale(values); ok {
return map[string]any{"en": values[locale]}
}
return map[string]any{}
}
func isUSEnglishLocale(locale string) bool {
switch normalizeLocale(locale) {
case "en", "en_us":
return true
default:
return false
}
}
func preferredEnglishLocale(values map[string]any) (string, bool) {
var locales []string
for locale := range values {
if isEnglishLocale(locale) {
locales = append(locales, locale)
}
}
if len(locales) == 0 {
return "", false
}
sortLocales(locales)
for _, preferred := range [...]string{"en_us", "en"} {
for _, locale := range locales {
if normalizeLocale(locale) == preferred {
return locale, true
}
}
}
return locales[0], true
}
func isEnglishLocale(locale string) bool {
normalized := normalizeLocale(locale)
return normalized == "en" || strings.HasPrefix(normalized, "en_")
}
func normalizeLocale(locale string) string {
return strings.ToLower(strings.ReplaceAll(locale, "-", "_"))
}
func sortLocales(locales []string) {
slices.SortFunc(locales, func(a, b string) int {
if cmp := strings.Compare(normalizeLocale(a), normalizeLocale(b)); cmp != 0 {
return cmp
}
return strings.Compare(a, b)
})
}
func flattenLocalizedValue(prefix string, value any, lines *[]string) {
switch typed := value.(type) {
case nil:
appendLocalizedLine(prefix, nil, lines)
case map[string]any:
flattenLocalizedMap(prefix, typed, lines)
case []any:
flattenLocalizedList(prefix, typed, lines)
default:
appendLocalizedLine(prefix, value, lines)
}
}
func flattenLocalizedMap(prefix string, values map[string]any, lines *[]string) {
if len(values) == 0 {
appendLocalizedLine(prefix, map[string]any{}, lines)
return
}
for _, key := range slices.Sorted(maps.Keys(values)) {
flattenLocalizedValue(joinLocalizedPath(prefix, key), values[key], lines)
}
}
func flattenLocalizedList(prefix string, values []any, lines *[]string) {
if len(values) == 0 {
appendLocalizedLine(prefix, []any{}, lines)
return
}
for idx, value := range values {
flattenLocalizedValue(fmt.Sprintf("%s[%d]", prefix, idx), value, lines)
}
}
func joinLocalizedPath(prefix, key string) string {
if prefix == "" {
return key
}
return prefix + "." + key
}
func appendLocalizedLine(prefix string, value any, lines *[]string) {
if prefix == "" {
prefix = "$"
}
encoded, err := json.Marshal(value)
if err != nil {
encoded = fmt.Appendf(nil, "%q", fmt.Sprint(value))
}
*lines = append(*lines, fmt.Sprintf("%s = %s", prefix, encoded))
}
func localizationDisplayName(path string) string {
if owner := localizationOwnerName(path); owner != "" {
return owner
}
base := filepath.Base(path)
ext := filepath.Ext(base)
if ext != "" {
base = strings.TrimSuffix(base, ext)
}
if base == "" || base == "." {
return "localization"
}
return base
}
func localizationOwnerName(path string) string {
parts := strings.Split(filepath.ToSlash(path), "/")
for idx := len(parts) - 2; idx >= 0; idx-- {
part := parts[idx]
if strings.HasSuffix(strings.ToLower(part), ".lproj") {
continue
}
if owner, ok := trimLocalizationOwnerExtension(part); ok {
return owner
}
}
return ""
}
func trimLocalizationOwnerExtension(name string) (string, bool) {
lower := strings.ToLower(name)
for _, ext := range localizationOwnerExtensions {
if strings.HasSuffix(lower, ext) {
trimmed := name[:len(name)-len(ext)]
if trimmed == "" {
break
}
return trimmed, true
}
}
return "", false
}
func diffLocalizedResources(out *PlistDiff, oldResources, newResources map[string]string) error {
prevFiles := sortedLocalizationPaths(oldResources)
nextFiles := sortedLocalizationPaths(newResources)
newFiles := utils.Difference(nextFiles, prevFiles)
out.Removed = utils.Difference(prevFiles, nextFiles)
slices.Sort(out.Removed)
newFileSet := make(map[string]struct{}, len(newFiles))
for _, path := range newFiles {
newFileSet[path] = struct{}{}
}
for _, path := range nextFiles {
nextContent := newResources[path]
if _, ok := newFileSet[path]; ok {
out.New[path] = nextContent
continue
}
prevContent, ok := oldResources[path]
if !ok || nextContent == prevContent {
continue
}
diff, err := utils.GitDiff(prevContent+"\n", nextContent+"\n", &utils.GitDiffConfig{Color: false, Tool: "git"})
if err != nil {
return err
}
if diff == "" {
continue
}
out.Updated[path] = "```diff\n" + diff + "\n```\n"
}
return nil
}
func sortedLocalizationPaths(resources map[string]string) []string {
return slices.Sorted(maps.Keys(resources))
}