package overlay import ( "context" "encoding/json" "fmt" "io" "io/fs" "os" "os/exec" "path/filepath" "strings" "sub2api-cn-relay-manager/internal/pack" ) const metadataFileName = ".sub2api-cn-relay-manager-overlay.json" type ApplyRequest struct { PackDir string SourceDir string OutputDir string Overlays []pack.HostOverlay } type ApplyResult struct { OutputDir string AppliedOverlays []pack.HostOverlay MetadataFilePath string } func Apply(ctx context.Context, req ApplyRequest) (_ ApplyResult, err error) { packDir := strings.TrimSpace(req.PackDir) sourceDir := strings.TrimSpace(req.SourceDir) if packDir == "" { return ApplyResult{}, fmt.Errorf("pack dir is required") } if sourceDir == "" { return ApplyResult{}, fmt.Errorf("source dir is required") } if len(req.Overlays) == 0 { return ApplyResult{}, fmt.Errorf("at least one host overlay is required") } packAbs, err := filepath.Abs(packDir) if err != nil { return ApplyResult{}, fmt.Errorf("resolve pack dir: %w", err) } sourceAbs, err := filepath.Abs(sourceDir) if err != nil { return ApplyResult{}, fmt.Errorf("resolve source dir: %w", err) } sourceInfo, err := os.Stat(sourceAbs) if err != nil { return ApplyResult{}, fmt.Errorf("stat source dir: %w", err) } if !sourceInfo.IsDir() { return ApplyResult{}, fmt.Errorf("source dir %q must be a directory", sourceAbs) } outputDir := strings.TrimSpace(req.OutputDir) if outputDir == "" { outputDir = defaultOutputDir(sourceAbs, req.Overlays) } outputAbs, err := filepath.Abs(outputDir) if err != nil { return ApplyResult{}, fmt.Errorf("resolve output dir: %w", err) } if outputAbs == sourceAbs { return ApplyResult{}, fmt.Errorf("output dir must differ from source dir") } if isPathWithin(outputAbs, sourceAbs) { return ApplyResult{}, fmt.Errorf("output dir %q must not be nested inside source dir %q", outputAbs, sourceAbs) } if _, err := os.Stat(outputAbs); err == nil { return ApplyResult{}, fmt.Errorf("output dir %q already exists", outputAbs) } else if !os.IsNotExist(err) { return ApplyResult{}, fmt.Errorf("stat output dir: %w", err) } if err := copyTree(sourceAbs, outputAbs); err != nil { return ApplyResult{}, fmt.Errorf("copy source dir: %w", err) } cleanupOutput := true defer func() { if cleanupOutput { _ = os.RemoveAll(outputAbs) } }() for _, hostOverlay := range req.Overlays { patchPath := strings.TrimSpace(hostOverlay.PatchPath) if patchPath == "" { return ApplyResult{}, fmt.Errorf("overlay %q does not define patch_path", hostOverlay.OverlayID) } patchAbs := filepath.Join(packAbs, patchPath) if err := applyPatchFile(ctx, outputAbs, patchAbs); err != nil { return ApplyResult{}, fmt.Errorf("apply overlay %q: %w", hostOverlay.OverlayID, err) } } metadataPath := filepath.Join(outputAbs, metadataFileName) if err := writeMetadata(metadataPath, sourceAbs, req.Overlays); err != nil { return ApplyResult{}, fmt.Errorf("write overlay metadata: %w", err) } cleanupOutput = false return ApplyResult{ OutputDir: outputAbs, AppliedOverlays: append([]pack.HostOverlay(nil), req.Overlays...), MetadataFilePath: metadataPath, }, nil } func FilterOverlays(overlays []pack.HostOverlay, overlayID string) ([]pack.HostOverlay, error) { trimmedOverlayID := strings.TrimSpace(overlayID) if trimmedOverlayID == "" { return append([]pack.HostOverlay(nil), overlays...), nil } filtered := make([]pack.HostOverlay, 0, len(overlays)) for _, hostOverlay := range overlays { if strings.TrimSpace(hostOverlay.OverlayID) == trimmedOverlayID { filtered = append(filtered, hostOverlay) } } if len(filtered) == 0 { return nil, fmt.Errorf("overlay %q did not match any resolved host overlays", trimmedOverlayID) } return filtered, nil } func applyPatchFile(ctx context.Context, outputDir string, patchPath string) error { if _, err := os.Stat(patchPath); err != nil { return fmt.Errorf("stat patch file %q: %w", patchPath, err) } cmd := exec.CommandContext(ctx, "patch", "-p1", "-i", patchPath, "-d", outputDir) output, err := cmd.CombinedOutput() if err != nil { message := strings.TrimSpace(string(output)) if message == "" { message = err.Error() } return fmt.Errorf("%s", message) } return nil } func writeMetadata(path string, sourceDir string, overlays []pack.HostOverlay) error { body, err := json.MarshalIndent(map[string]any{ "source_dir": sourceDir, "applied_overlays": overlays, }, "", " ") if err != nil { return err } return os.WriteFile(path, append(body, '\n'), 0o644) } func defaultOutputDir(sourceDir string, overlays []pack.HostOverlay) string { baseName := filepath.Base(sourceDir) overlaySuffix := "overlay" if len(overlays) > 0 { overlaySuffix = sanitizePathToken(overlays[0].OverlayID) if overlaySuffix == "" { overlaySuffix = "overlay" } } return filepath.Join(filepath.Dir(sourceDir), baseName+"-patched-"+overlaySuffix) } func sanitizePathToken(value string) string { value = strings.ToLower(strings.TrimSpace(value)) var b strings.Builder lastDash := false for _, r := range value { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) lastDash = false case !lastDash: b.WriteByte('-') lastDash = true } } return strings.Trim(b.String(), "-") } func isPathWithin(target string, root string) bool { rel, err := filepath.Rel(root, target) if err != nil { return false } return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) } func copyTree(sourceDir string, outputDir string) error { if err := os.MkdirAll(outputDir, 0o755); err != nil { return err } return filepath.WalkDir(sourceDir, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } rel, err := filepath.Rel(sourceDir, path) if err != nil { return err } if rel == "." { return nil } if rel == ".git" || strings.HasPrefix(rel, ".git"+string(filepath.Separator)) { if d.IsDir() { return filepath.SkipDir } return nil } targetPath := filepath.Join(outputDir, rel) info, err := d.Info() if err != nil { return err } switch { case d.IsDir(): return os.MkdirAll(targetPath, info.Mode().Perm()) case info.Mode()&os.ModeSymlink != 0: linkTarget, err := os.Readlink(path) if err != nil { return err } return os.Symlink(linkTarget, targetPath) default: return copyFile(path, targetPath, info.Mode().Perm()) } }) } func copyFile(sourcePath string, targetPath string, perm fs.FileMode) error { if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { return err } sourceFile, err := os.Open(sourcePath) if err != nil { return err } defer sourceFile.Close() targetFile, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) if err != nil { return err } defer targetFile.Close() if _, err := io.Copy(targetFile, sourceFile); err != nil { return err } return nil }