package main import ( "encoding/json" "errors" "flag" "fmt" "io" "log" "os" "path/filepath" "runtime" "strings" "time" ) const ( pairsFileName = "pairs.json" reportFileName = "restore_report.json" logFileName = "restore.log" pairMetaName = ".dinsar_pair.json" tempSuffix = ".tmp" ) type config struct { inputRoot string outputRoot string skipExisting bool overwrite bool limit int dryRun bool checkOnly bool logWriter io.Writer } type pairsDocument struct { Schema string `json:"schema"` ExportedAt string `json:"exported_at"` Pairs []pair `json:"pairs"` } type pair struct { PairID string `json:"pair_id"` TaskName string `json:"task_name"` TaskAlias string `json:"task_alias"` MasterData string `json:"master_data"` SlaveData string `json:"slave_data"` MasterOrbit string `json:"master_orbit"` SlaveOrbit string `json:"slave_orbit"` MasterSourcePath string `json:"master_source_path"` SlaveSourcePath string `json:"slave_source_path"` MasterOrbitSource string `json:"master_orbit_source_path"` SlaveOrbitSource string `json:"slave_orbit_source_path"` MasterImagingDate string `json:"master_imaging_date"` SlaveImagingDate string `json:"slave_imaging_date"` TimeBaselineDays int `json:"time_baseline_days"` } type pairMetadata struct { PairID string `json:"pair_id"` TaskName string `json:"task_name"` TaskAlias string `json:"task_alias"` MasterData string `json:"master_data"` SlaveData string `json:"slave_data"` MasterOrbit string `json:"master_orbit"` SlaveOrbit string `json:"slave_orbit"` MasterSourcePath string `json:"master_source_path,omitempty"` SlaveSourcePath string `json:"slave_source_path,omitempty"` MasterOrbitSource string `json:"master_orbit_source_path,omitempty"` SlaveOrbitSource string `json:"slave_orbit_source_path,omitempty"` MasterImagingDate string `json:"master_imaging_date"` SlaveImagingDate string `json:"slave_imaging_date"` TimeBaselineDays int `json:"time_baseline_days"` RestoredAt string `json:"restored_at"` } type restoreReport struct { StartedAt string `json:"started_at"` FinishedAt string `json:"finished_at"` InputRoot string `json:"input_root"` OutputRoot string `json:"output_root"` TotalPairs int `json:"total_pairs"` Restored int `json:"restored"` Skipped int `json:"skipped"` Failed int `json:"failed"` Warnings []string `json:"warnings"` } type restoreResult struct { report restoreReport } type checkReport struct { StartedAt string `json:"started_at"` FinishedAt string `json:"finished_at"` InputRoot string `json:"input_root"` TotalPairs int `json:"total_pairs"` Checked int `json:"checked"` Valid int `json:"valid"` Failed int `json:"failed"` Warnings []string `json:"warnings"` Errors []string `json:"errors"` } type checkResult struct { report checkReport } func main() { if len(os.Args) == 1 && runtime.GOOS == "windows" { if err := runGUI(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } return } cfg, err := parseFlags(os.Args[1:]) if err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(2) } if cfg.checkOnly { result, err := runCheck(cfg) if err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } fmt.Printf("check: total=%d checked=%d valid=%d failed=%d warnings=%d\n", result.report.TotalPairs, result.report.Checked, result.report.Valid, result.report.Failed, len(result.report.Warnings), ) if result.report.Failed > 0 { os.Exit(1) } return } result, err := run(cfg) if err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } fmt.Printf("done: restored=%d skipped=%d failed=%d warnings=%d\n", result.report.Restored, result.report.Skipped, result.report.Failed, len(result.report.Warnings), ) } func parseFlags(args []string) (config, error) { var cfg config fs := flag.NewFlagSet("dinsar-restore", flag.ContinueOnError) fs.SetOutput(io.Discard) fs.StringVar(&cfg.inputRoot, "input", "", "source bundle root") fs.StringVar(&cfg.outputRoot, "output", "", "restored output root") fs.BoolVar(&cfg.skipExisting, "skip-existing", true, "skip existing completed tasks") fs.BoolVar(&cfg.overwrite, "overwrite", false, "delete and rebuild existing task directories") fs.IntVar(&cfg.limit, "limit", 0, "maximum number of pairs to restore") fs.BoolVar(&cfg.dryRun, "dry-run", false, "print plan without copying files") fs.BoolVar(&cfg.checkOnly, "check-only", false, "validate the source bundle without restoring") if err := fs.Parse(args); err != nil { return cfg, err } explicitFlags := map[string]bool{} fs.Visit(func(f *flag.Flag) { explicitFlags[f.Name] = true }) if cfg.inputRoot == "" { return cfg, errors.New("missing --input") } if cfg.outputRoot == "" && !cfg.checkOnly { return cfg, errors.New("missing --output") } if cfg.overwrite && explicitFlags["skip-existing"] { return cfg, errors.New("--skip-existing and --overwrite cannot be used together") } if cfg.overwrite { cfg.skipExisting = false } if cfg.limit < 0 { return cfg, errors.New("--limit must be zero or greater") } return cfg, nil } func run(cfg config) (restoreResult, error) { started := time.Now().UTC() inputRoot, err := filepath.Abs(cfg.inputRoot) if err != nil { return restoreResult{}, fmt.Errorf("resolve input root: %w", err) } outputRoot, err := filepath.Abs(cfg.outputRoot) if err != nil { return restoreResult{}, fmt.Errorf("resolve output root: %w", err) } report := restoreReport{ StartedAt: started.Format(time.RFC3339), InputRoot: inputRoot, OutputRoot: outputRoot, Warnings: []string{}, } pairsDoc, err := loadAndValidateInput(inputRoot) if err != nil { return restoreResult{}, err } report.TotalPairs = len(pairsDoc.Pairs) if !cfg.dryRun { if err := os.MkdirAll(outputRoot, 0755); err != nil { return restoreResult{}, fmt.Errorf("create output root: %w", err) } } logOutput := defaultLogWriter(cfg.logWriter) logger, logFile, err := newLogger(outputRoot, cfg.dryRun, logOutput) if err != nil { return restoreResult{}, err } if logFile != nil { defer logFile.Close() } plannedPairs := pairsDoc.Pairs logger.Printf("started input=%s output=%s total_pairs=%d planned_pairs=%d dry_run=%t", inputRoot, outputRoot, report.TotalPairs, len(plannedPairs), cfg.dryRun) limitCount := 0 for i, p := range plannedPairs { if cfg.limit > 0 && limitCount >= cfg.limit { logger.Printf("limit reached processed=%d limit=%d", limitCount, cfg.limit) break } issues := checkPair(inputRoot, p, false) if len(issues.errors) > 0 { report.Failed++ for _, item := range issues.errors { logger.Printf("failed pair_index=%d pair_id=%s error=%s", i, p.PairID, item) } continue } warnings, err := restorePair(cfg, inputRoot, outputRoot, p, logger) report.Warnings = append(report.Warnings, warnings...) if err != nil { if errors.Is(err, errSkipped) { report.Skipped++ continue } report.Failed++ logger.Printf("failed pair_index=%d pair_id=%s error=%v", i, p.PairID, err) continue } limitCount++ if !cfg.dryRun { report.Restored++ } } report.FinishedAt = time.Now().UTC().Format(time.RFC3339) logger.Printf("finished restored=%d skipped=%d failed=%d warnings=%d", report.Restored, report.Skipped, report.Failed, len(report.Warnings)) if !cfg.dryRun { if err := writeJSON(filepath.Join(outputRoot, reportFileName), report); err != nil { return restoreResult{}, fmt.Errorf("write report: %w", err) } } else { if err := json.NewEncoder(logOutput).Encode(report); err != nil { return restoreResult{}, fmt.Errorf("print dry-run report: %w", err) } } return restoreResult{report: report}, nil } func runCheck(cfg config) (checkResult, error) { started := time.Now().UTC() inputRoot, err := filepath.Abs(cfg.inputRoot) if err != nil { return checkResult{}, fmt.Errorf("resolve input root: %w", err) } report := checkReport{ StartedAt: started.Format(time.RFC3339), InputRoot: inputRoot, Warnings: []string{}, Errors: []string{}, } logOutput := defaultLogWriter(cfg.logWriter) logger := log.New(logOutput, "", log.LstdFlags) logger.Printf("check started input=%s", inputRoot) pairsDoc, err := loadAndValidateInput(inputRoot) if err != nil { report.Failed = 1 report.Errors = append(report.Errors, err.Error()) report.FinishedAt = time.Now().UTC().Format(time.RFC3339) printCheckReport(logOutput, report) return checkResult{report: report}, nil } report.TotalPairs = len(pairsDoc.Pairs) plannedPairs := pairsDoc.Pairs if cfg.limit > 0 && cfg.limit < len(plannedPairs) { plannedPairs = plannedPairs[:cfg.limit] } seenTasks := map[string]int{} for i, p := range plannedPairs { report.Checked++ issues := checkPair(inputRoot, p, true) taskName, taskNameErr := taskDirectoryName(p) if taskNameErr == nil { if firstIndex, exists := seenTasks[taskName]; exists { issues.errors = append(issues.errors, fmt.Sprintf("duplicate task directory %s also used by pair index %d", taskName, firstIndex)) } else { seenTasks[taskName] = i } } if len(issues.warnings) > 0 { report.Warnings = append(report.Warnings, issues.warnings...) for _, warning := range issues.warnings { logger.Printf("warning pair_index=%d pair_id=%s %s", i, p.PairID, warning) } } if len(issues.errors) > 0 { report.Failed++ for _, item := range issues.errors { message := fmt.Sprintf("pair_index=%d pair_id=%s %s", i, p.PairID, item) report.Errors = append(report.Errors, message) logger.Printf("error %s", message) } continue } report.Valid++ logger.Printf("valid pair_index=%d pair_id=%s task=%s", i, p.PairID, taskName) } report.FinishedAt = time.Now().UTC().Format(time.RFC3339) logger.Printf("check finished total=%d checked=%d valid=%d failed=%d warnings=%d", report.TotalPairs, report.Checked, report.Valid, report.Failed, len(report.Warnings)) printCheckReport(logOutput, report) return checkResult{report: report}, nil } type pairCheckIssues struct { warnings []string errors []string } func checkPair(inputRoot string, p pair, includeOrbitWarnings bool) pairCheckIssues { var issues pairCheckIssues if _, err := taskDirectoryName(p); err != nil { issues.errors = append(issues.errors, err.Error()) } if err := validatePairPaths(inputRoot, p); err != nil { issues.errors = append(issues.errors, err.Error()) return issues } masterSource, _ := safeJoin(inputRoot, p.MasterData) slaveSource, _ := safeJoin(inputRoot, p.SlaveData) if err := requireDir(masterSource, "master_data"); err != nil { issues.errors = append(issues.errors, err.Error()) } else if nonEmpty, err := dirNonEmpty(masterSource); err != nil { issues.errors = append(issues.errors, fmt.Sprintf("check master_data contents: %v", err)) } else if !nonEmpty { issues.errors = append(issues.errors, fmt.Sprintf("master_data directory is empty: %s", p.MasterData)) } if err := requireDir(slaveSource, "slave_data"); err != nil { issues.errors = append(issues.errors, err.Error()) } else if nonEmpty, err := dirNonEmpty(slaveSource); err != nil { issues.errors = append(issues.errors, fmt.Sprintf("check slave_data contents: %v", err)) } else if !nonEmpty { issues.errors = append(issues.errors, fmt.Sprintf("slave_data directory is empty: %s", p.SlaveData)) } if includeOrbitWarnings { issues.warnings = append(issues.warnings, checkOrbitWarnings(inputRoot, p)...) } return issues } func checkOrbitWarnings(inputRoot string, p pair) []string { taskName, err := taskDirectoryName(p) if err != nil { taskName = pairLabel(p) } return orbitWarnings(inputRoot, p, taskName) } func printCheckReport(writer io.Writer, report checkReport) { if writer == nil { return } encoder := json.NewEncoder(writer) encoder.SetIndent("", " ") _ = encoder.Encode(report) } func loadAndValidateInput(inputRoot string) (pairsDocument, error) { if stat, err := os.Stat(inputRoot); err != nil { return pairsDocument{}, fmt.Errorf("input root is not accessible: %w", err) } else if !stat.IsDir() { return pairsDocument{}, fmt.Errorf("input root is not a directory: %s", inputRoot) } dataDir := filepath.Join(inputRoot, "data") if stat, err := os.Stat(dataDir); err != nil { return pairsDocument{}, fmt.Errorf("data directory is not accessible: %w", err) } else if !stat.IsDir() { return pairsDocument{}, fmt.Errorf("data path is not a directory: %s", dataDir) } pairsPath := filepath.Join(inputRoot, pairsFileName) file, err := os.Open(pairsPath) if err != nil { return pairsDocument{}, fmt.Errorf("open pairs.json: %w", err) } defer file.Close() var doc pairsDocument decoder := json.NewDecoder(file) if err := decoder.Decode(&doc); err != nil { return pairsDocument{}, fmt.Errorf("parse pairs.json: %w", err) } if len(doc.Pairs) == 0 { return pairsDocument{}, errors.New("pairs.json contains no pairs") } return doc, nil } func validatePairPaths(inputRoot string, p pair) error { if strings.TrimSpace(p.MasterData) == "" { return fmt.Errorf("pair %s missing master_data", pairLabel(p)) } if strings.TrimSpace(p.SlaveData) == "" { return fmt.Errorf("pair %s missing slave_data", pairLabel(p)) } if _, err := safeJoin(inputRoot, p.MasterData); err != nil { return fmt.Errorf("pair %s invalid master_data: %w", pairLabel(p), err) } if _, err := safeJoin(inputRoot, p.SlaveData); err != nil { return fmt.Errorf("pair %s invalid slave_data: %w", pairLabel(p), err) } if strings.TrimSpace(p.MasterOrbit) != "" { if _, err := safeJoin(inputRoot, p.MasterOrbit); err != nil { return fmt.Errorf("pair %s invalid master_orbit: %w", pairLabel(p), err) } } if strings.TrimSpace(p.SlaveOrbit) != "" { if _, err := safeJoin(inputRoot, p.SlaveOrbit); err != nil { return fmt.Errorf("pair %s invalid slave_orbit: %w", pairLabel(p), err) } } return nil } var errSkipped = errors.New("skipped") func restorePair(cfg config, inputRoot string, outputRoot string, p pair, logger *log.Logger) ([]string, error) { taskDirName, err := taskDirectoryName(p) if err != nil { return nil, err } taskDir := filepath.Join(outputRoot, taskDirName) if !isSubpath(outputRoot, taskDir) { return nil, fmt.Errorf("task directory escapes output root: %s", taskDirName) } tempDir := taskDir + tempSuffix masterSource, _ := safeJoin(inputRoot, p.MasterData) slaveSource, _ := safeJoin(inputRoot, p.SlaveData) if err := requireDir(masterSource, "master_data"); err != nil { return nil, err } if err := requireDir(slaveSource, "slave_data"); err != nil { return nil, err } existingComplete, err := taskComplete(taskDir) if err != nil { return nil, err } if existingComplete && cfg.skipExisting { logger.Printf("skip existing task=%s", taskDirName) return nil, errSkipped } if cfg.dryRun { logger.Printf("dry-run restore task=%s master=%s slave=%s", taskDirName, p.MasterData, p.SlaveData) return orbitWarnings(inputRoot, p, taskDirName), nil } if pathExists(taskDir) && !cfg.overwrite { return nil, fmt.Errorf("task already exists and is not complete; use --overwrite or remove it: %s", taskDir) } if cfg.overwrite { if err := os.RemoveAll(taskDir); err != nil { return nil, fmt.Errorf("remove existing task %s: %w", taskDir, err) } } if pathExists(tempDir) { if err := os.RemoveAll(tempDir); err != nil { return nil, fmt.Errorf("remove stale temp directory %s: %w", tempDir, err) } } logger.Printf("restore task=%s", taskDirName) if err := os.MkdirAll(tempDir, 0755); err != nil { return nil, fmt.Errorf("create temp task directory: %w", err) } cleanupTemp := true defer func() { if cleanupTemp { _ = os.RemoveAll(tempDir) } }() if err := copyDataTree(masterSource, filepath.Join(tempDir, "master"), p.MasterData, p.MasterSourcePath); err != nil { return nil, fmt.Errorf("copy master data: %w", err) } if err := copyDataTree(slaveSource, filepath.Join(tempDir, "slave"), p.SlaveData, p.SlaveSourcePath); err != nil { return nil, fmt.Errorf("copy slave data: %w", err) } warnings := copyOrbitFiles(inputRoot, tempDir, taskDirName, p, logger) meta := pairMetadata{ PairID: p.PairID, TaskName: p.TaskName, TaskAlias: p.TaskAlias, MasterData: p.MasterData, SlaveData: p.SlaveData, MasterOrbit: p.MasterOrbit, SlaveOrbit: p.SlaveOrbit, MasterSourcePath: p.MasterSourcePath, SlaveSourcePath: p.SlaveSourcePath, MasterOrbitSource: p.MasterOrbitSource, SlaveOrbitSource: p.SlaveOrbitSource, MasterImagingDate: p.MasterImagingDate, SlaveImagingDate: p.SlaveImagingDate, TimeBaselineDays: p.TimeBaselineDays, RestoredAt: time.Now().UTC().Format(time.RFC3339), } if err := writeJSON(filepath.Join(tempDir, pairMetaName), meta); err != nil { return warnings, fmt.Errorf("write pair metadata: %w", err) } if err := validateRestoredTask(tempDir); err != nil { return warnings, err } if err := os.Rename(tempDir, taskDir); err != nil { return warnings, fmt.Errorf("publish restored task: %w", err) } cleanupTemp = false return warnings, nil } func taskDirectoryName(p pair) (string, error) { for _, candidate := range []string{p.TaskAlias, p.TaskName, p.PairID} { name := strings.TrimSpace(candidate) if name == "" { continue } if name != filepath.Base(name) || strings.ContainsAny(name, `/\:`) { return "", fmt.Errorf("invalid task directory name: %q", name) } return name, nil } return "", errors.New("pair has no task_alias, task_name, or pair_id") } func copyOrbitFiles(inputRoot string, tempDir string, taskDirName string, p pair, logger *log.Logger) []string { var warnings []string for _, entry := range []struct { label string value string sourcePath string }{ {label: "master_orbit", value: p.MasterOrbit, sourcePath: p.MasterOrbitSource}, {label: "slave_orbit", value: p.SlaveOrbit, sourcePath: p.SlaveOrbitSource}, } { if strings.TrimSpace(entry.value) == "" { continue } destName, err := restoredOrbitFileName(entry.value, entry.sourcePath) if err != nil { warning := fmt.Sprintf("task %s %s cannot determine restored file name: %v", taskDirName, entry.label, err) warnings = append(warnings, warning) logger.Printf("warning %s", warning) continue } source, err := safeJoin(inputRoot, entry.value) if err != nil { warning := fmt.Sprintf("task %s %s invalid: %v", taskDirName, entry.label, err) warnings = append(warnings, warning) logger.Printf("warning %s", warning) continue } stat, err := os.Stat(source) if err != nil { warning := fmt.Sprintf("task %s %s missing: %s", taskDirName, entry.label, entry.value) warnings = append(warnings, warning) logger.Printf("warning %s", warning) continue } if stat.IsDir() { warning := fmt.Sprintf("task %s %s is a directory, expected file: %s", taskDirName, entry.label, entry.value) warnings = append(warnings, warning) logger.Printf("warning %s", warning) continue } orbitDir := filepath.Join(tempDir, "orbit") if err := os.MkdirAll(orbitDir, 0755); err != nil { warning := fmt.Sprintf("task %s cannot create orbit directory: %v", taskDirName, err) warnings = append(warnings, warning) logger.Printf("warning %s", warning) continue } if err := copyFile(source, filepath.Join(orbitDir, destName), stat.Mode()); err != nil { warning := fmt.Sprintf("task %s cannot copy %s %s: %v", taskDirName, entry.label, entry.value, err) warnings = append(warnings, warning) logger.Printf("warning %s", warning) continue } } return warnings } func orbitWarnings(inputRoot string, p pair, taskDirName string) []string { var warnings []string for _, entry := range []struct { label string value string sourcePath string }{ {label: "master_orbit", value: p.MasterOrbit, sourcePath: p.MasterOrbitSource}, {label: "slave_orbit", value: p.SlaveOrbit, sourcePath: p.SlaveOrbitSource}, } { if strings.TrimSpace(entry.value) == "" { continue } if _, err := restoredOrbitFileName(entry.value, entry.sourcePath); err != nil { warnings = append(warnings, fmt.Sprintf("task %s %s cannot determine restored file name: %v", taskDirName, entry.label, err)) continue } source, err := safeJoin(inputRoot, entry.value) if err != nil { warnings = append(warnings, fmt.Sprintf("task %s %s invalid: %v", taskDirName, entry.label, err)) continue } if stat, err := os.Stat(source); err != nil { warnings = append(warnings, fmt.Sprintf("task %s %s missing: %s", taskDirName, entry.label, entry.value)) } else if stat.IsDir() { warnings = append(warnings, fmt.Sprintf("task %s %s is a directory, expected file: %s", taskDirName, entry.label, entry.value)) } } return warnings } func copyDataTree(sourceDir string, destRoleDir string, bundlePath string, sourcePath string) error { rootName, hasRootName, err := restoredDataRootName(bundlePath, sourcePath) if err != nil { return err } if hasRootName { if nestedSource, ok, err := singleNestedDataRoot(sourceDir, rootName); err != nil { return err } else if ok { sourceDir = nestedSource } } return copyDirContentsRenamed(sourceDir, destRoleDir, restoredDataEntryName) } func singleNestedDataRoot(sourceDir string, rootName string) (string, bool, error) { entries, err := os.ReadDir(sourceDir) if err != nil { return "", false, err } if len(entries) != 1 { return "", false, nil } entry := entries[0] if !entry.IsDir() { return "", false, nil } if restoredDataEntryName(entry.Name()) != rootName { return "", false, nil } return filepath.Join(sourceDir, entry.Name()), true, nil } func restoredDataRootName(bundlePath string, sourcePath string) (string, bool, error) { if name, ok, err := originalNameFromSourcePath(sourcePath); err != nil { return "", false, err } else if ok { return name, true, nil } name := pathLeafName(bundlePath) if stripped := stripBundlePrefix(name, "scene"); stripped != name { if err := validateRestoredLeafName(stripped); err != nil { return "", false, err } return stripped, true, nil } return "", false, nil } func restoredDataEntryName(name string) string { return stripBundlePrefix(name, "scene") } func restoredOrbitFileName(bundlePath string, sourcePath string) (string, error) { if name, ok, err := originalNameFromSourcePath(sourcePath); err != nil { return "", err } else if ok { return name, nil } name := stripBundlePrefix(pathLeafName(bundlePath), "orbit") if err := validateRestoredLeafName(name); err != nil { return "", err } return name, nil } func originalNameFromSourcePath(sourcePath string) (string, bool, error) { name := pathLeafName(sourcePath) if name == "" { return "", false, nil } switch strings.ToLower(name) { case "master", "slave", "data", "orbit": return "", false, nil } if err := validateRestoredLeafName(name); err != nil { return "", false, err } return name, true, nil } func stripBundlePrefix(name string, prefix string) string { rest, ok := strings.CutPrefix(name, prefix+"_") if !ok { return name } hashEnd := strings.IndexByte(rest, '_') if hashEnd <= 0 || hashEnd == len(rest)-1 { return name } if !looksLikeHashToken(rest[:hashEnd]) { return name } return rest[hashEnd+1:] } func looksLikeHashToken(value string) bool { if len(value) < 6 { return false } for _, r := range value { switch { case r >= '0' && r <= '9': case r >= 'a' && r <= 'f': case r >= 'A' && r <= 'F': default: return false } } return true } func pathLeafName(value string) string { value = strings.TrimSpace(value) value = strings.TrimRight(value, `/\`) if value == "" { return "" } index := strings.LastIndexAny(value, `/\`) if index >= 0 { return value[index+1:] } return value } func validateRestoredLeafName(name string) error { if name == "" || name == "." || name == ".." { return fmt.Errorf("invalid empty file name") } if strings.ContainsAny(name, `<>:"/\|?*`) { return fmt.Errorf("invalid file name %q", name) } if strings.HasSuffix(name, " ") || strings.HasSuffix(name, ".") { return fmt.Errorf("invalid file name %q", name) } for _, r := range name { if r < 32 { return fmt.Errorf("invalid file name %q", name) } } return nil } func copyDirContents(sourceDir string, destDir string) error { return copyDirContentsRenamed(sourceDir, destDir, func(name string) string { return name }) } func copyDirContentsRenamed(sourceDir string, destDir string, renameEntry func(string) string) error { sourceEntries, err := os.ReadDir(sourceDir) if err != nil { return err } if err := os.MkdirAll(destDir, 0755); err != nil { return err } for _, entry := range sourceEntries { sourcePath := filepath.Join(sourceDir, entry.Name()) destName := renameEntry(entry.Name()) if err := validateRestoredLeafName(destName); err != nil { return err } destPath := filepath.Join(destDir, destName) info, err := entry.Info() if err != nil { return err } mode := info.Mode() switch { case mode&os.ModeSymlink != 0: return fmt.Errorf("symlinks are not supported: %s", sourcePath) case info.IsDir(): if err := copyDirContentsRenamed(sourcePath, destPath, renameEntry); err != nil { return err } case mode.IsRegular(): if err := copyFile(sourcePath, destPath, mode); err != nil { return err } default: return fmt.Errorf("unsupported file type: %s", sourcePath) } } return nil } func copyFile(sourcePath string, destPath string, mode os.FileMode) error { if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { return err } source, err := os.Open(sourcePath) if err != nil { return err } defer source.Close() dest, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) if err != nil { return err } if _, err := io.Copy(dest, source); err != nil { _ = dest.Close() return err } return dest.Close() } func taskComplete(taskDir string) (bool, error) { masterNonEmpty, err := dirNonEmpty(filepath.Join(taskDir, "master")) if err != nil { return false, err } slaveNonEmpty, err := dirNonEmpty(filepath.Join(taskDir, "slave")) if err != nil { return false, err } return masterNonEmpty && slaveNonEmpty, nil } func validateRestoredTask(taskDir string) error { masterNonEmpty, err := dirNonEmpty(filepath.Join(taskDir, "master")) if err != nil { return fmt.Errorf("validate master directory: %w", err) } if !masterNonEmpty { return errors.New("restored Task/master is empty") } slaveNonEmpty, err := dirNonEmpty(filepath.Join(taskDir, "slave")) if err != nil { return fmt.Errorf("validate slave directory: %w", err) } if !slaveNonEmpty { return errors.New("restored Task/slave is empty") } if _, err := os.Stat(filepath.Join(taskDir, pairMetaName)); err != nil { return fmt.Errorf("validate pair metadata: %w", err) } return nil } func dirNonEmpty(path string) (bool, error) { entries, err := os.ReadDir(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return false, nil } return false, err } return len(entries) > 0, nil } func requireDir(path string, label string) error { stat, err := os.Stat(path) if err != nil { return fmt.Errorf("%s is not accessible: %w", label, err) } if !stat.IsDir() { return fmt.Errorf("%s is not a directory: %s", label, path) } return nil } func writeJSON(path string, value any) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { return err } defer file.Close() encoder := json.NewEncoder(file) encoder.SetIndent("", " ") return encoder.Encode(value) } func newLogger(outputRoot string, dryRun bool, output io.Writer) (*log.Logger, *os.File, error) { if output == nil { output = io.Discard } if dryRun { return log.New(output, "", log.LstdFlags), nil, nil } if err := os.MkdirAll(outputRoot, 0755); err != nil { return nil, nil, err } logFile, err := os.OpenFile(filepath.Join(outputRoot, logFileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { return nil, nil, err } writer := io.MultiWriter(output, logFile) return log.New(writer, "", log.LstdFlags), logFile, nil } func defaultLogWriter(writer io.Writer) io.Writer { if writer != nil { return writer } return os.Stdout } func safeJoin(root string, relative string) (string, error) { if filepath.IsAbs(relative) { return "", fmt.Errorf("absolute path is not allowed: %s", relative) } cleanRelative := filepath.Clean(relative) if cleanRelative == "." || strings.HasPrefix(cleanRelative, ".."+string(filepath.Separator)) || cleanRelative == ".." { return "", fmt.Errorf("path escapes root: %s", relative) } joined := filepath.Join(root, cleanRelative) if !isSubpath(root, joined) { return "", fmt.Errorf("path escapes root: %s", relative) } return joined, nil } func isSubpath(root string, path string) bool { absRoot, err := filepath.Abs(root) if err != nil { return false } absPath, err := filepath.Abs(path) if err != nil { return false } rel, err := filepath.Rel(absRoot, absPath) if err != nil { return false } return rel == "." || (!strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..") } func pathExists(path string) bool { _, err := os.Stat(path) return err == nil } func pairLabel(p pair) string { if strings.TrimSpace(p.PairID) != "" { return p.PairID } if strings.TrimSpace(p.TaskName) != "" { return p.TaskName } if strings.TrimSpace(p.TaskAlias) != "" { return p.TaskAlias } return "" }