diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f5573f8b..ef918cc7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -37,7 +37,7 @@ jobs: cache: false - name: Initialize CodeQL - uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 with: languages: go @@ -46,6 +46,6 @@ jobs: run: go build ./... - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 with: category: "/language:go" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..d4428c79 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,40 @@ +name: Go Lint + +run-name: Go lint - ${{ github.ref_name }} + +"on": + push: + branches: + - main + - dev + pull_request: {} + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: golangci-lint + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 + with: + go-version-file: go.mod + cache: false + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 + with: + version: v2.12.2 diff --git a/.github/workflows/security-ultimate.yml b/.github/workflows/security-ultimate.yml index f9a42935..12b868d1 100644 --- a/.github/workflows/security-ultimate.yml +++ b/.github/workflows/security-ultimate.yml @@ -93,7 +93,7 @@ jobs: # UPLOAD SARIF ######################################## - name: Upload GoSec SARIF - uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 with: sarif_file: gosec.sarif diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..a239d225 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,42 @@ +version: "2" + +run: + timeout: 5m + modules-download-mode: readonly + +linters: + default: standard + settings: + errcheck: + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + staticcheck: + # `checks` REPLACES golangci-lint's default set (it does not append), so + # the six ST* checks disabled by default must be restated or they turn + # back on. -QF1001 (De Morgan) and -QF1002 (tagged switch) are deliberate + # style opt-outs; -fmt.Fprint* errcheck is handled above by convention. + checks: + - all + - -ST1000 + - -ST1003 + - -ST1016 + - -ST1020 + - -ST1021 + - -ST1022 + - -QF1001 + - -QF1002 + exclusions: + rules: + # Deferred Close / best-effort cleanup in tests is idiomatic; keep every + # other linter (staticcheck, unused, ...) active on test files. + - path: _test\.go$ + linters: + - errcheck + +issues: + # Defaults (50 / 3) truncated the reported set and made it nondeterministic; + # uncap so the gate sees the whole codebase. + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/README.md b/README.md index b9c89da9..160cb3f3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![License: PolyForm NC](https://img.shields.io/badge/license-PolyForm%20Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/) [![Go](https://img.shields.io/badge/Go-1.25+-success.svg?logo=go)](https://go.dev/) [![codecov](https://codecov.io/gh/tis24dev/cPanel_self-migration/graph/badge.svg?branch=main)](https://codecov.io/gh/tis24dev/cPanel_self-migration) -[![Go Report Card](https://goreportcard.com/badge/github.com/tis24dev/cPanel_self-migration)](https://goreportcard.com/report/github.com/tis24dev/cPanel_self-migration) +[![Go Lint](https://github.com/tis24dev/cPanel_self-migration/actions/workflows/lint.yml/badge.svg?branch=dev)](https://github.com/tis24dev/cPanel_self-migration/actions/workflows/lint.yml) [![GoSec](https://img.shields.io/github/actions/workflow/status/tis24dev/cPanel_self-migration/security-ultimate.yml?label=GoSec&logo=go)](https://github.com/tis24dev/cPanel_self-migration/actions/workflows/security-ultimate.yml) [![CodeQL](https://img.shields.io/github/actions/workflow/status/tis24dev/cPanel_self-migration/codeql.yml?label=CodeQL&logo=github)](https://github.com/tis24dev/cPanel_self-migration/actions/workflows/codeql.yml) [![Dependabot](https://img.shields.io/badge/Dependabot-enabled-success?logo=dependabot)](https://github.com/tis24dev/cPanel_self-migration/network/updates) diff --git a/cmd/cpanel-self-migration/cron_apply_cmd.go b/cmd/cpanel-self-migration/cron_apply_cmd.go new file mode 100644 index 00000000..d6241c37 --- /dev/null +++ b/cmd/cpanel-self-migration/cron_apply_cmd.go @@ -0,0 +1,609 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/config" + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "github.com/tis24dev/cPanel_self-migration/internal/sshx" +) + +// runCronApplyCmd implements `cpanel-self-migration cron apply`: the cron +// writer (PR 2A). It consumes an offline cron_apply_plan.json and installs +// new cron entries onto the DESTINATION account only (sshx.DialDest, the +// source is never dialed, let alone written). +// +// The write primitive is `crontab -` which replaces the ENTIRE crontab: +// the apply reads the current crontab, merges the planned create ops, and +// installs the merged result in one shot. +// +// House contract: +// - without --yes-apply-writes: fully offline preview, ZERO connections; +// - backup-or-nothing before the first write; +// - PlanTimeDestCrontab guard: refuse when the crontab changed since plan +// (currently inert, the plan builder does not yet populate the field; +// activated when the plan carries a non-empty hash); +// - verify-after: re-read and check installed lines present; +// - --rollback : InstallCrontab with the backup content, verify. +// +// Exit codes: 0 ok; 1 input/runtime/write failure; 2 flags; 3 gated +// refusal (refused_precondition ops, stale crontab, or refused rollback). +func runCronApplyCmd(args []string) int { + fs := flag.NewFlagSet("cron apply", flag.ContinueOnError) + planPath := fs.String("plan", "", "path to cron_apply_plan.json (required unless --rollback)") + cfgFlag := fs.String("config", "", "path to host.yaml (default: configs/host.yaml or host.yaml)") + yes := fs.Bool("yes-apply-writes", false, "actually write to the DESTINATION (default: fully offline preview, zero connections)") + rollbackPath := fs.String("rollback", "", "path to a cron apply backup JSON: restore the backed-up crontab") + backupFlag := fs.String("backup", "", "pre-write backup path (default: cron_backup__.json)") + outJSON := fs.String("output-json", "", "report JSON path (default: cron_apply_report.json, or cron_rollback_report.json with --rollback)") + outMD := fs.String("output-md", "", "report Markdown path (default: derived from --output-json)") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration cron apply --plan cron_apply_plan.json [--yes-apply-writes] [--config host.yaml] [--backup PATH]") + fmt.Fprintln(os.Stderr, " cpanel-self-migration cron apply --rollback cron_backup_....json [--yes-apply-writes]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + + if *rollbackPath != "" { + if *planPath != "" { + fmt.Fprintln(os.Stderr, "error: --plan and --rollback are mutually exclusive") + return 2 + } + return runCronRollback(*rollbackPath, *yes, *cfgFlag, *outJSON, *outMD) + } + if *planPath == "" { + fmt.Fprintln(os.Stderr, "error: --plan is required") + fs.Usage() + return 1 + } + + plan, err := loadCronPlanFile(*planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if plan.FormatVersion != 1 { + fmt.Fprintf(os.Stderr, "error: %s: unsupported plan format_version %d (this build understands 1)\n", *planPath, plan.FormatVersion) + return 1 + } + + if !*yes { + printCronApplyDryRun(plan) + return 0 + } + return runCronApplyWrites(plan, *planPath, *cfgFlag, *backupFlag, *outJSON, *outMD) +} + +// printCronApplyDryRun is the offline preview: no config, no SSH, no +// artifact files. +func printCronApplyDryRun(plan accountinventory.CronApplyPlan) { + fmt.Println("cron apply, DRY-RUN (fully offline: no connection was opened, nothing was written).") + fmt.Println() + writes := 0 + for _, op := range plan.Ops { + switch op.Action { + case accountinventory.CronActionCreate: + fmt.Printf(" create %s %s\n", op.Section, op.Key) + writes++ + case accountinventory.CronActionSkip: + fmt.Printf(" skip %s %s\n", op.Section, op.Key) + case accountinventory.CronActionManual: + fmt.Printf(" manual %s %s (%s)\n", op.Section, op.Key, op.Reason) + } + } + if writes == 0 { + fmt.Println(" (no writable ops in this plan)") + } + fmt.Printf("\nplan summary: %d create, %d skip, %d manual, %d informational\n", + plan.Summary.Create, plan.Summary.Skip, plan.Summary.Manual, plan.Summary.Informational) + fmt.Println("to apply: re-run with --yes-apply-writes") +} + +// runCronApplyWrites is the write path: read current crontab, stale guard, +// merge, backup-or-nothing, install, verify-after, report. +func runCronApplyWrites(plan accountinventory.CronApplyPlan, planPath, cfgFlag, backupFlag, outJSON, outMD string) int { + planSHA, err := fileSHA256(planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if outJSON == "" { + outJSON = "cron_apply_report.json" + } + if outMD == "" { + outMD = deriveMDPath(outJSON) + } + + // Count writable ops. + var createOps []accountinventory.CronPlanOp + for _, op := range plan.Ops { + if op.Action == accountinventory.CronActionCreate { + createOps = append(createOps, op) + } + } + + // Build results for non-create ops first. + results := make([]accountinventory.CronApplyOpResult, 0, len(plan.Ops)) + createIndices := map[int]int{} // plan.Ops index -> results index + createIdx := 0 + for i, op := range plan.Ops { + res := accountinventory.CronApplyOpResult{CronPlanOp: op} + switch op.Action { + case accountinventory.CronActionSkip: + res.Status = accountinventory.CronOpSkipped + case accountinventory.CronActionManual: + res.Status = accountinventory.CronOpManual + case accountinventory.CronActionCreate: + res.Status = "" // will be set below + createIndices[i] = len(results) + createIdx++ + default: + res.Status = accountinventory.CronOpRefused + res.StatusReason = fmt.Sprintf("unknown plan action %q", op.Action) + } + results = append(results, res) + } + + report := accountinventory.CronApplyReport{ + Mode: "cron-apply-report", FormatVersion: 1, RunMode: "apply", + DestinationUser: plan.DestinationUser, + PlanFile: planPath, PlanSHA256: planSHA, + } + + if len(createOps) == 0 { + // Nothing to write: no connection needed. + report.BackupNote = "no write was decided (every op skipped, manual, or refused), nothing to back up" + for i := range results { + if results[i].Status == "" { + results[i].Status = accountinventory.CronOpSkipped + } + } + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Results = results + report.Summary = accountinventory.SummarizeCronResults(results) + return finishCronReport(report, outJSON, outMD) + } + + // Dial BOTH hosts: the SOURCE (read-only) to re-collect the crontab + // fresh in the clear, and the DESTINATION to read + install. Dev never + // persists cron clear-text to the inventory (secret hygiene), so the + // installable command is sourced live and kept in memory only; the + // offline plan supplies the reviewed decisions (which entries to create), + // never the secret payload. Verify and rollback stay dest-only. + ctx := context.Background() + pool, err := dialCronBoth(ctx, cfgFlag) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + defer func() { _ = pool.Close() }() + + // Fresh source crontab (verbatim, in the clear). Parsed in memory only; + // the raw source text and its clear commands are NEVER written to disk. + sourceRaw, err := cpanel.ReadCrontabRaw(ctx, pool.Src) + if err != nil { + fmt.Fprintln(os.Stderr, "error: read source crontab:", err) + return 1 + } + srcParsed := cpanel.ParseCrontab(sourceRaw) + + // Read current destination crontab. + currentCrontab, err := cpanel.ReadCrontabRaw(ctx, pool.Dest) + if err != nil { + fmt.Fprintln(os.Stderr, "error: read destination crontab:", err) + return 1 + } + currentHash := accountinventory.CronPlanDestCrontabHash(currentCrontab) + + // Stale-crontab guard: refuse when the crontab changed since plan. + if plan.PlanTimeDestCrontab != "" && plan.PlanTimeDestCrontab != currentHash { + fmt.Fprintln(os.Stderr, "refused: the destination crontab changed since the plan was built, re-plan") + // Refuse all create ops. + for i, op := range plan.Ops { + if op.Action == accountinventory.CronActionCreate { + idx := createIndices[i] + results[idx].Status = accountinventory.CronOpRefused + results[idx].StatusReason = "crontab changed since plan (stale-plan guard)" + } + } + report.BackupNote = "no write was attempted (stale-plan guard refused all creates)" + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Results = results + report.Summary = accountinventory.SummarizeCronResults(results) + return finishCronReport(report, outJSON, outMD) + } + + // Build merged crontab: current content + each create op's CLEAR line, + // resolved fresh from the source parse (env lines first, then jobs). A + // create op whose source entry is gone (source changed since plan) is + // refused, never guessed or installed redacted. + var envLines, jobLines []string + for i, op := range plan.Ops { + if op.Action != accountinventory.CronActionCreate { + continue + } + idx := createIndices[i] + clearLine, ok := resolveCronCreateLine(op, srcParsed, plan.SourceUser, plan.DestinationUser) + if !ok { + results[idx].Status = accountinventory.CronOpRefused + results[idx].StatusReason = "the source crontab no longer carries this entry (redacted key " + op.Key + "); re-plan" + continue + } + switch op.Section { + case accountinventory.CronSectionEnv: + envLines = append(envLines, clearLine) + case accountinventory.CronSectionJobs: + jobLines = append(jobLines, clearLine) + } + } + + // If every create was refused, nothing is left to write: record and stop + // before touching the destination (no backup, no install). + if len(envLines) == 0 && len(jobLines) == 0 { + report.BackupNote = "no create op could be resolved from the current source crontab (all refused); nothing was written" + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Results = results + report.Summary = accountinventory.SummarizeCronResults(results) + return finishCronReport(report, outJSON, outMD) + } + + merged := currentCrontab + if !strings.HasSuffix(merged, "\n") && merged != "" { + merged += "\n" + } + for _, l := range envLines { + merged += l + "\n" + } + for _, l := range jobLines { + merged += l + "\n" + } + + // Backup-or-nothing: the DESTINATION crontab (the customer's own data) + // is written 0600 BEFORE the write. The source clear-text is never part + // of any backup. + backupPath := backupFlag + if backupPath == "" { + backupPath = filepath.Join(filepath.Dir(outJSON), + fmt.Sprintf("cron_backup_%s_%s.json", plan.DestinationUser, time.Now().UTC().Format("20060102-150405"))) + } + backup := accountinventory.CronApplyBackup{ + Mode: "cron-apply-backup", FormatVersion: 1, + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + DestinationUser: plan.DestinationUser, + PlanFile: planPath, PlanSHA256: planSHA, + ReportFile: outJSON, + RawCrontab: currentCrontab, + CrontabSHA256: currentHash, + } + if err := accountinventory.WriteCronApplyBackupJSON(backupPath, backup); err != nil { + fmt.Fprintln(os.Stderr, "error: backup-or-nothing, backup write failed, NOTHING was written:", err) + return 1 + } + backupSHA, err := fileSHA256(backupPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error: backup-or-nothing, cannot hash the backup, NOTHING was written:", err) + return 1 + } + report.BackupFile, report.BackupSHA256 = backupPath, backupSHA + fmt.Fprintf(os.Stderr, "wrote %s (pre-write backup)\n", backupPath) + + // Install the merged crontab onto the destination. + if err := cpanel.InstallCrontab(ctx, pool.Dest, merged); err != nil { + fmt.Fprintln(os.Stderr, "error: install crontab:", err) + for i, op := range plan.Ops { + if op.Action != accountinventory.CronActionCreate { + continue + } + idx := createIndices[i] + if results[idx].Status == accountinventory.CronOpRefused { + continue // resolution refused this op; it was never in the merge + } + results[idx].Status = accountinventory.CronOpFailed + results[idx].StatusReason = "install failed: " + err.Error() + } + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Results = results + report.Summary = accountinventory.SummarizeCronResults(results) + return finishCronReport(report, outJSON, outMD) + } + + // Verify-after: re-read the destination and confirm each installed entry + // is present, matched by its canonical REDACTED identity. The installed + // line is in the clear; comparing whole-entry redacted forms avoids both + // substring collisions and clear-vs-redacted mismatches. InstalledCrontab + // is intentionally NOT persisted: the post-install crontab now carries the + // freshly migrated clear secrets, and the report is a durable artifact. + afterCrontab, err := cpanel.ReadCrontabRaw(ctx, pool.Dest) + if err != nil { + fmt.Fprintln(os.Stderr, "warning: verify-after read failed:", err) + for i, op := range plan.Ops { + if op.Action != accountinventory.CronActionCreate { + continue + } + idx := createIndices[i] + if results[idx].Status == accountinventory.CronOpRefused { + continue + } + results[idx].Status = accountinventory.CronOpFailed + results[idx].StatusReason = "verify-after read failed: " + err.Error() + } + } else { + destSet := destCronLineSet(cpanel.ParseCrontab(afterCrontab)) + for i, op := range plan.Ops { + if op.Action != accountinventory.CronActionCreate { + continue + } + idx := createIndices[i] + if results[idx].Status == accountinventory.CronOpRefused { + continue + } + if op.Line != "" && destSet[op.Line] { + results[idx].Status = accountinventory.CronOpApplied + } else { + results[idx].Status = accountinventory.CronOpFailed + results[idx].StatusReason = "install reported success but the entry is not in the post-install crontab" + } + } + } + + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Results = results + report.Summary = accountinventory.SummarizeCronResults(results) + return finishCronReport(report, outJSON, outMD) +} + +// runCronRollback restores the crontab from a backup. +func runCronRollback(backupPath string, yes bool, cfgFlag, outJSON, outMD string) int { + backup, err := loadCronBackupFile(backupPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if backup.FormatVersion != 1 { + fmt.Fprintf(os.Stderr, "error: %s: unsupported backup format_version %d\n", backupPath, backup.FormatVersion) + return 1 + } + + if !yes { + fmt.Println("cron rollback, DRY-RUN (fully offline: no connection was opened, nothing was written).") + fmt.Printf(" would restore crontab from %s (%d bytes, sha256 %s)\n", + backupPath, len(backup.RawCrontab), backup.CrontabSHA256) + fmt.Println("to apply: re-run with --yes-apply-writes") + return 0 + } + + if outJSON == "" { + outJSON = "cron_rollback_report.json" + } + if outMD == "" { + outMD = deriveMDPath(outJSON) + } + + ctx := context.Background() + client, err := dialCronDest(ctx, cfgFlag) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + defer func() { _ = client.Close() }() + + report := accountinventory.CronApplyReport{ + Mode: "cron-apply-report", FormatVersion: 1, RunMode: "rollback", + DestinationUser: backup.DestinationUser, + BackupFile: backupPath, + } + if report.BackupSHA256, err = fileSHA256(backupPath); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + if err := cpanel.InstallCrontab(ctx, client, backup.RawCrontab); err != nil { + fmt.Fprintln(os.Stderr, "error: rollback install:", err) + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Results = []accountinventory.CronApplyOpResult{{ + CronPlanOp: accountinventory.CronPlanOp{Section: "rollback", Key: "crontab"}, + Status: accountinventory.CronOpFailed, + StatusReason: err.Error(), + }} + report.Summary = accountinventory.SummarizeCronResults(report.Results) + return finishCronReport(report, outJSON, outMD) + } + + // Verify the restore. + afterCrontab, err := cpanel.ReadCrontabRaw(ctx, client) + result := accountinventory.CronApplyOpResult{ + CronPlanOp: accountinventory.CronPlanOp{Section: "rollback", Key: "crontab"}, + } + if err != nil { + result.Status = accountinventory.CronOpFailed + result.StatusReason = "verify-after read failed: " + err.Error() + } else { + afterHash := crontabSHA256(afterCrontab) + backupHash := crontabSHA256(backup.RawCrontab) + if afterHash == backupHash { + result.Status = accountinventory.CronOpApplied + } else { + result.Status = accountinventory.CronOpFailed + result.StatusReason = "installed crontab does not match the backup content" + } + } + + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Results = []accountinventory.CronApplyOpResult{result} + report.Summary = accountinventory.SummarizeCronResults(report.Results) + return finishCronReport(report, outJSON, outMD) +} + +// dialCronDest resolves the config and dials the DESTINATION. +func dialCronDest(ctx context.Context, cfgFlag string) (*sshx.Client, error) { + path, alternates, err := resolveConfigPath(cfgFlag) + if err != nil { + return nil, err + } + for _, alt := range alternates { + fmt.Fprintf(os.Stderr, "note: multiple host.yaml candidates, using %s (ignoring %s)\n", path, alt) + } + cfg, err := config.Load(path) + if err != nil { + return nil, err + } + if !cfg.DestConfigured() { + return nil, fmt.Errorf("the cron commands need the DESTINATION host configured in %s", path) + } + client, err := sshx.DialDest(ctx, cfg, "") + if err != nil { + return nil, fmt.Errorf("dial destination: %w", err) + } + return client, nil +} + +// dialCronBoth resolves the config and dials BOTH hosts: the source +// (read-only, for a fresh clear-text crontab collection) and the +// destination (read + install). Only the apply write path needs the +// source; verify and rollback use dest-only dialCronDest. +func dialCronBoth(ctx context.Context, cfgFlag string) (*sshx.Pool, error) { + path, alternates, err := resolveConfigPath(cfgFlag) + if err != nil { + return nil, err + } + for _, alt := range alternates { + fmt.Fprintf(os.Stderr, "note: multiple host.yaml candidates, using %s (ignoring %s)\n", path, alt) + } + cfg, err := config.Load(path) + if err != nil { + return nil, err + } + if !cfg.DestConfigured() { + return nil, fmt.Errorf("the cron apply command needs the DESTINATION host configured in %s", path) + } + pool, err := sshx.DialBoth(ctx, cfg, "") + if err != nil { + return nil, fmt.Errorf("dial hosts: %w", err) + } + if pool.Src == nil || pool.Dest == nil { + _ = pool.Close() + return nil, fmt.Errorf("cron apply needs both the source and the destination reachable") + } + return pool, nil +} + +// resolveCronCreateLine returns the CLEAR, installable crontab line for a +// create op, sourced fresh from the parsed source crontab and matched by the +// op's redacted identity (jobs by redacted command, env by name). The job +// line is home-path adapted (/home// -> /home//) to mirror the +// plan's PathAdapted decision. Returns ("", false) when the source no longer +// carries the entry, so the caller refuses rather than installs a guess. +func resolveCronCreateLine(op accountinventory.CronPlanOp, src cpanel.CrontabResult, srcUser, destUser string) (string, bool) { + switch op.Section { + case accountinventory.CronSectionJobs: + for _, j := range src.Jobs { + if !j.Enabled || j.CommandRedacted != op.Key { + continue + } + // Match the FULL reviewed identity (schedule + redacted command, + // = op.SourceLine, the pre-path-adaptation plan line). If the + // source schedule drifted since the plan, refuse rather than + // install an unreviewed schedule. + if op.SourceLine != "" && canonicalRedactedCronJobLine(j) != op.SourceLine { + continue + } + return adaptCronHomePath(j.RawLine, srcUser, destUser), true + } + case accountinventory.CronSectionEnv: + for _, e := range src.Environment { + if e.Name == op.Key { + return e.Name + "=" + e.ValueRaw, true + } + } + } + return "", false +} + +// adaptCronHomePath replaces /home// with /home// in a +// crontab line, mirroring the plan builder's adaptCronPath. A no-op when the +// users match or the path is absent. +func adaptCronHomePath(line, srcUser, destUser string) string { + if srcUser == destUser || srcUser == "" || destUser == "" { + return line + } + return strings.ReplaceAll(line, "/home/"+srcUser+"/", "/home/"+destUser+"/") +} + +// finishCronReport writes both report artifacts and translates the +// summary into the exit code. +func finishCronReport(report accountinventory.CronApplyReport, outJSON, outMD string) int { + if err := accountinventory.WriteCronApplyReportJSON(outJSON, report); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := accountinventory.WriteCronApplyReportMarkdown(outMD, report); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + s := report.Summary + fmt.Printf("cron %s: %d applied, %d skipped, %d manual, %d failed, %d refused (precondition)\n", + report.RunMode, s.Applied, s.Skipped, s.Manual, s.Failed, s.Refused) + fmt.Fprintf(os.Stderr, "wrote %s\nwrote %s\n", outJSON, outMD) + switch { + case s.Failed > 0: + fmt.Fprintln(os.Stderr, "one or more ops FAILED, exiting 1 (see the report)") + return 1 + case s.Refused > 0: + fmt.Fprintln(os.Stderr, "one or more ops were refused by the freshness guard, exiting 3 (re-plan and review)") + return exitDriftGate + } + return 0 +} + +// loadCronPlanFile reads and minimally validates a cron apply plan. +func loadCronPlanFile(path string) (accountinventory.CronApplyPlan, error) { + var p accountinventory.CronApplyPlan + b, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return p, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(b, &p); err != nil { + return p, fmt.Errorf("parse %s: %w", path, err) + } + if p.Mode != "cron-apply-plan" { + return p, fmt.Errorf("%s: not a cron apply plan (mode %q)", path, p.Mode) + } + return p, nil +} + +// loadCronBackupFile reads and minimally validates a cron apply backup. +func loadCronBackupFile(path string) (accountinventory.CronApplyBackup, error) { + var b accountinventory.CronApplyBackup + raw, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return b, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(raw, &b); err != nil { + return b, fmt.Errorf("parse %s: %w", path, err) + } + if b.Mode != "cron-apply-backup" { + return b, fmt.Errorf("%s: not a cron apply backup (mode %q)", path, b.Mode) + } + return b, nil +} + +// crontabSHA256 returns the hex SHA256 of a crontab string. +func crontabSHA256(content string) string { + h := sha256.Sum256([]byte(content)) + return hex.EncodeToString(h[:]) +} diff --git a/cmd/cpanel-self-migration/cron_apply_cmd_test.go b/cmd/cpanel-self-migration/cron_apply_cmd_test.go new file mode 100644 index 00000000..2862ff53 --- /dev/null +++ b/cmd/cpanel-self-migration/cron_apply_cmd_test.go @@ -0,0 +1,453 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/sshtest" +) + +// --- stateful crontab stub --------------------------------------------------- + +// cronStubScript is a STATEFUL bash stub that stores the crontab in +// $HOME/crontab.txt. sshtest gives each server its own HOME, so two servers +// (source, destination) keep INDEPENDENT crontab state through the same +// on-PATH stub. It handles `crontab -l` (read) and `crontab -` (write from +// stdin, the pipeline InstallCrontab uses). Raw content only: the real +// crontabScript in cron.go wraps the output with the __CRONTAB_RC marker. +const cronStubScript = `#!/bin/bash +CT="$HOME/crontab.txt" +touch "$CT" +case "$1" in + -l) cat "$CT" ;; + -) cat > "$CT" ;; + *) echo "stub: unknown crontab call: $*" >&2; exit 1 ;; +esac +` + +// setupCronServer starts TWO in-process SSH servers (source + destination), +// each with its own HOME so their crontab state is independent, wires the +// stateful crontab stub onto PATH, and writes a host.yaml pointing src at the +// source server and dest at the destination server. It returns the config +// path and the two HOME dirs, used to seed/read each side's crontab. +func setupCronServer(t *testing.T) (cfgPath, srcHome, destHome string) { + t.Helper() + tmp := t.TempDir() + stubDir := filepath.Join(tmp, "bin") + srcHome = filepath.Join(tmp, "src") + destHome = filepath.Join(tmp, "dest") + for _, d := range []string{stubDir, srcHome, destHome} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(stubDir, "crontab"), []byte(cronStubScript), 0o755); err != nil { // #nosec G306 -- stub must be executable + t.Fatal(err) + } + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HOME", tmp) // the TEST process HOME (known_hosts TOFU for DialBoth) + + srcAddr := sshtest.NewExecServer(t, srcHome) + destAddr := sshtest.NewExecServer(t, destHome) + sh, sp, err := net.SplitHostPort(srcAddr) + if err != nil { + t.Fatal(err) + } + dh, dp, err := net.SplitHostPort(destAddr) + if err != nil { + t.Fatal(err) + } + cfgPath = filepath.Join(tmp, "host.yaml") + yaml := fmt.Sprintf(`src: + ip: %[1]s + port: %[2]s + ssh_user: u + ssh_pass: p + timeout: 10s +dest: + ip: %[3]s + port: %[4]s + ssh_user: u + ssh_pass: p + timeout: 10s +`, sh, sp, dh, dp) + if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + return cfgPath, srcHome, destHome +} + +func setCronStubState(t *testing.T, home, crontab string) { + t.Helper() + if err := os.WriteFile(filepath.Join(home, "crontab.txt"), + []byte(crontab), 0o600); err != nil { + t.Fatal(err) + } +} + +func readCronStubState(t *testing.T, home string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(home, "crontab.txt")) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func buildCronTestPlan(t *testing.T, dir string) string { + t.Helper() + src := writeCronInventory(t, dir, "src.json", "source", "acct", + []accountinventory.CronJobEntry{ + { + Type: "schedule", Minute: "0", Hour: "3", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/true", CommandClear: "/usr/bin/true", + CommandSHA256: "sha256:abc", RawLine: "0 3 * * * /usr/bin/true", + Enabled: true, CommandCollected: true, + }, + }, + []accountinventory.CronEnvEntry{ + {Name: "MAILTO", ValueRedacted: "test@example.com", ValueClear: "test@example.com", ValueCollected: true}, + }) + dest := writeCronInventory(t, dir, "dest.json", "destination", "acct", nil, nil) + planPath := filepath.Join(dir, "cron_apply_plan.json") + if code := runInventoryCronPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", planPath, "--output-md", filepath.Join(dir, "cron_apply_plan.md"), + }); code != 0 { + t.Fatalf("cron-plan: code = %d, want 0", code) + } + return planPath +} + +func readCronApplyReport(t *testing.T, path string) accountinventory.CronApplyReport { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read report: %v", err) + } + var r accountinventory.CronApplyReport + if err := json.Unmarshal(b, &r); err != nil { + t.Fatalf("parse report: %v", err) + } + return r +} + +// --- flag/input errors ------------------------------------------------------- + +func TestCronApplyCmdFlagAndInputErrors(t *testing.T) { + dir := t.TempDir() + if code := runCronApplyCmd([]string{"--bogus"}); code != 2 { + t.Errorf("unknown flag: code = %d, want 2", code) + } + if code := runCronApplyCmd([]string{}); code != 1 { + t.Errorf("missing --plan: code = %d, want 1", code) + } + if code := runCronApplyCmd([]string{"--plan", "x.json", "--rollback", "y.json"}); code != 2 { + t.Errorf("--plan + --rollback: code = %d, want 2", code) + } + badMode := filepath.Join(dir, "badmode.json") + if err := os.WriteFile(badMode, []byte(`{"mode":"dns-import-plan"}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runCronApplyCmd([]string{"--plan", badMode}); code != 1 { + t.Errorf("wrong mode: code = %d, want 1", code) + } + badVer := filepath.Join(dir, "badver.json") + if err := os.WriteFile(badVer, []byte(`{"mode":"cron-apply-plan","format_version":9}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runCronApplyCmd([]string{"--plan", badVer}); code != 1 { + t.Errorf("unknown format_version: code = %d, want 1", code) + } +} + +// The default dry-run is fully offline: no config, no connections. +func TestCronApplyCmdDryRunIsOffline(t *testing.T) { + dir := t.TempDir() + planPath := buildCronTestPlan(t, dir) + + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + empty := t.TempDir() + if err := os.Chdir(empty); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(cwd) }() + + if code := runCronApplyCmd([]string{"--plan", planPath}); code != 0 { + t.Fatalf("dry-run: code = %d, want 0 (offline, no config needed)", code) + } + entries, err := os.ReadDir(empty) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Errorf("dry-run wrote artifacts: %v", entries) + } +} + +// --- apply end-to-end -------------------------------------------------------- + +func TestCronApplyCmdEndToEnd(t *testing.T) { + cfgPath, srcHome, destHome := setupCronServer(t) + dir := t.TempDir() + planPath := buildCronTestPlan(t, dir) + // The SOURCE carries the entries to migrate (in the clear); the + // DESTINATION has existing content that must survive the merge. + setCronStubState(t, srcHome, "0 3 * * * /usr/bin/true\nMAILTO=test@example.com\n") + setCronStubState(t, destHome, "# existing crontab\n*/5 * * * * /usr/bin/existing\n") + + outJSON := filepath.Join(dir, "cron_apply_report.json") + backupPath := filepath.Join(dir, "cron_backup_test.json") + + code := runCronApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }) + if code != 0 { + t.Fatalf("apply: code = %d, want 0", code) + } + + rep := readCronApplyReport(t, outJSON) + if rep.Summary.Applied != 2 || rep.Summary.Failed != 0 || rep.Summary.Refused != 0 { + t.Fatalf("summary = %+v, want 2 applied (1 job + 1 env)", rep.Summary) + } + + // The destination crontab contains the existing content PLUS new lines. + ct := readCronStubState(t, destHome) + if !strings.Contains(ct, "/usr/bin/existing") { + t.Error("existing crontab entry was lost during merge") + } + if !strings.Contains(ct, "0 3 * * * /usr/bin/true") { + t.Error("new cron job was not installed") + } + if !strings.Contains(ct, "MAILTO=test@example.com") { + t.Error("new env line was not installed") + } + + // Bidirectional pairing: report <-> backup. + if rep.BackupFile != backupPath || rep.BackupSHA256 == "" { + t.Errorf("report backup pairing = %q/%q", rep.BackupFile, rep.BackupSHA256) + } + b, err := os.ReadFile(backupPath) + if err != nil { + t.Fatal(err) + } + var backup accountinventory.CronApplyBackup + if err := json.Unmarshal(b, &backup); err != nil { + t.Fatal(err) + } + if backup.Mode != "cron-apply-backup" || backup.ReportFile != outJSON { + t.Errorf("backup = mode %q report %q", backup.Mode, backup.ReportFile) + } + // The backup holds the PRE-write crontab. + if !strings.Contains(backup.RawCrontab, "/usr/bin/existing") { + t.Error("backup should contain the pre-write crontab") + } + if strings.Contains(backup.RawCrontab, "/usr/bin/true") { + t.Error("backup should NOT contain the newly applied job") + } +} + +// A source job carrying a secret must install the CLEAR command on the +// destination (re-collected fresh from the live source), NEVER the redacted +// [REDACTED] placeholder, and the secret must not leak into the report. This +// is the regression test for the Batch-D secret-hygiene design: the offline +// plan carries only the redacted command; the clear text is sourced live. +func TestCronApplyCmdInstallsClearNotRedacted(t *testing.T) { + cfgPath, srcHome, destHome := setupCronServer(t) + dir := t.TempDir() + // The inventory records the REDACTED command (as the real collector does). + src := writeCronInventory(t, dir, "src.json", "source", "acct", + []accountinventory.CronJobEntry{ + { + Type: "schedule", Minute: "0", Hour: "4", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "curl https://api.example.com/?secure=[REDACTED]", + CommandSHA256: "sha256:sec", + Enabled: true, CommandCollected: true, + }, + }, nil) + dest := writeCronInventory(t, dir, "dest.json", "destination", "acct", nil, nil) + planPath := filepath.Join(dir, "plan.json") + if code := runInventoryCronPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", planPath, "--output-md", filepath.Join(dir, "plan.md"), + }); code != 0 { + t.Fatalf("plan: code = %d", code) + } + // The LIVE source crontab carries the secret in the clear. + setCronStubState(t, srcHome, "0 4 * * * curl https://api.example.com/?secure=TOPSECRET\n") + setCronStubState(t, destHome, "") + + outJSON := filepath.Join(dir, "report.json") + code := runCronApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", filepath.Join(dir, "backup.json"), "--output-json", outJSON, + }) + if code != 0 { + t.Fatalf("apply: code = %d, want 0", code) + } + rep := readCronApplyReport(t, outJSON) + if rep.Summary.Applied != 1 { + t.Fatalf("summary = %+v, want 1 applied", rep.Summary) + } + // The destination got the CLEAR secret, not the placeholder. + ct := readCronStubState(t, destHome) + if !strings.Contains(ct, "secure=TOPSECRET") { + t.Errorf("destination crontab missing the clear secret: %q", ct) + } + if strings.Contains(ct, "[REDACTED]") { + t.Error("destination crontab installed the REDACTED placeholder (data corruption)") + } + // The secret must NOT leak into the persisted report (InstalledCrontab dropped). + rb, err := os.ReadFile(outJSON) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(rb), "TOPSECRET") { + t.Error("report artifact leaked the clear secret") + } +} + +// A source job whose schedule drifted since the plan (same command, new +// schedule) must be REFUSED, never installed with the unreviewed schedule. +func TestCronApplyCmdRefusesSourceScheduleDrift(t *testing.T) { + cfgPath, srcHome, destHome := setupCronServer(t) + dir := t.TempDir() + src := writeCronInventory(t, dir, "src.json", "source", "acct", + []accountinventory.CronJobEntry{ + { + Type: "schedule", Minute: "0", Hour: "3", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/true", CommandSHA256: "sha256:abc", + Enabled: true, CommandCollected: true, + }, + }, nil) + dest := writeCronInventory(t, dir, "dest.json", "destination", "acct", nil, nil) + planPath := filepath.Join(dir, "plan.json") + if code := runInventoryCronPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", planPath, "--output-md", filepath.Join(dir, "plan.md"), + }); code != 0 { + t.Fatalf("plan: code = %d", code) + } + // The LIVE source runs the SAME command on a DIFFERENT schedule. + setCronStubState(t, srcHome, "30 6 * * * /usr/bin/true\n") + setCronStubState(t, destHome, "") + + outJSON := filepath.Join(dir, "report.json") + code := runCronApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", filepath.Join(dir, "backup.json"), "--output-json", outJSON, + }) + if code != exitDriftGate { + t.Fatalf("apply: code = %d, want %d (all creates refused)", code, exitDriftGate) + } + rep := readCronApplyReport(t, outJSON) + if rep.Summary.Refused != 1 || rep.Summary.Applied != 0 { + t.Errorf("summary = %+v, want 1 refused", rep.Summary) + } + if ct := readCronStubState(t, destHome); strings.Contains(ct, "/usr/bin/true") { + t.Errorf("a schedule-drifted job must not be installed: %q", ct) + } +} + +// No-op plan: no creates, no SSH, exit 0. +func TestCronApplyCmdNoCreates(t *testing.T) { + dir := t.TempDir() + job := accountinventory.CronJobEntry{ + Type: "schedule", Minute: "0", Hour: "3", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/true", CommandClear: "/usr/bin/true", + CommandSHA256: "sha256:abc", RawLine: "0 3 * * * /usr/bin/true", + Enabled: true, CommandCollected: true, + } + src := writeCronInventory(t, dir, "src.json", "source", "acct", + []accountinventory.CronJobEntry{job}, nil) + dest := writeCronInventory(t, dir, "dest.json", "destination", "acct", + []accountinventory.CronJobEntry{job}, nil) + planPath := filepath.Join(dir, "plan.json") + if code := runInventoryCronPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", planPath, "--output-md", filepath.Join(dir, "plan.md"), + }); code != 0 { + t.Fatalf("plan: code = %d", code) + } + + outJSON := filepath.Join(dir, "report.json") + // Apply with --yes: all-skip plan needs no SSH; should succeed + // even without a config file. + cwd, _ := os.Getwd() + empty := t.TempDir() + _ = os.Chdir(empty) + defer func() { _ = os.Chdir(cwd) }() + + code := runCronApplyCmd([]string{ + "--plan", planPath, "--yes-apply-writes", + "--output-json", outJSON, + }) + if code != 0 { + t.Fatalf("all-skip apply: code = %d, want 0", code) + } + rep := readCronApplyReport(t, outJSON) + if rep.Summary.Skipped != 1 || rep.Summary.Applied != 0 { + t.Errorf("summary = %+v, want 1 skipped", rep.Summary) + } +} + +// --- rollback ---------------------------------------------------------------- + +func TestCronApplyCmdRollbackEndToEnd(t *testing.T) { + cfgPath, srcHome, destHome := setupCronServer(t) + dir := t.TempDir() + planPath := buildCronTestPlan(t, dir) + setCronStubState(t, srcHome, "0 3 * * * /usr/bin/true\nMAILTO=test@example.com\n") + setCronStubState(t, destHome, "# original\n") + + outJSON := filepath.Join(dir, "cron_apply_report.json") + backupPath := filepath.Join(dir, "cron_backup_test.json") + if code := runCronApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }); code != 0 { + t.Fatalf("apply: code = %d", code) + } + + // Rollback dry-run: offline. + rbJSON := filepath.Join(dir, "cron_rollback_report.json") + if code := runCronApplyCmd([]string{"--rollback", backupPath, "--output-json", rbJSON}); code != 0 { + t.Fatalf("rollback dry-run: code = %d, want 0", code) + } + if _, err := os.Stat(rbJSON); !os.IsNotExist(err) { + t.Error("rollback dry-run must not write a report") + } + + // Real rollback. + if code := runCronApplyCmd([]string{ + "--rollback", backupPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", rbJSON, + }); code != 0 { + t.Fatalf("rollback: code = %d, want 0", code) + } + rb := readCronApplyReport(t, rbJSON) + if rb.RunMode != "rollback" || rb.Summary.Applied != 1 { + t.Fatalf("rollback report = run_mode %q summary %+v", rb.RunMode, rb.Summary) + } + // The crontab must be back to the original. + ct := readCronStubState(t, destHome) + if !strings.Contains(ct, "# original") { + t.Error("rollback did not restore the original crontab") + } + if strings.Contains(ct, "/usr/bin/true") { + t.Error("rollback should have removed the applied job") + } +} diff --git a/cmd/cpanel-self-migration/cron_verify_cmd.go b/cmd/cpanel-self-migration/cron_verify_cmd.go new file mode 100644 index 00000000..2c2a059a --- /dev/null +++ b/cmd/cpanel-self-migration/cron_verify_cmd.go @@ -0,0 +1,333 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" +) + +// CronVerifyOpResult is the per-op outcome of a cron verify run. +type CronVerifyOpResult struct { + accountinventory.CronPlanOp + Status string `json:"status"` + StatusReason string `json:"status_reason,omitempty"` +} + +// CronVerifySummary counts verify results. +type CronVerifySummary struct { + Applied int `json:"applied"` + Unchanged int `json:"unchanged"` + Pending int `json:"pending"` + Drift int `json:"drift"` + Manual int `json:"manual"` + NotChecked int `json:"not_checked"` +} + +// CronVerifyReport records what a `cron verify` run found. +type CronVerifyReport struct { + Mode string `json:"mode"` // "cron-verify-report" + FormatVersion int `json:"format_version"` + GeneratedAt string `json:"generated_at"` + PlanFile string `json:"plan_file"` + PlanSHA256 string `json:"plan_sha256"` + Clean bool `json:"clean"` + Results []CronVerifyOpResult `json:"results"` + Summary CronVerifySummary `json:"summary"` +} + +// runCronVerifyCmd implements `cpanel-self-migration cron verify`: re-read +// the DESTINATION crontab and report, per planned op, whether the live +// crontab matches the plan (mirror of dns verify / email verify). The +// source host is never dialed. +// +// Exit codes: 0 verify ran and reports were written (even with drift); +// 1 invalid input, config/SSH-dial failure or write failure; 2 flag +// parsing; 3 gated refusal (stale plan, or --fail-on-drift + not clean). +func runCronVerifyCmd(args []string) int { + fs := flag.NewFlagSet("cron verify", flag.ContinueOnError) + planPath := fs.String("plan", "", "path to cron_apply_plan.json (required)") + cfgFlag := fs.String("config", "", "path to host.yaml (default: configs/host.yaml or host.yaml)") + srcInv := fs.String("source", "", "optional source inventory JSON: refuse the plan unless its embedded source_sha256 matches this file") + destInv := fs.String("destination", "", "optional destination inventory JSON: refuse the plan unless its embedded destination_sha256 matches this file") + outJSON := fs.String("output-json", "cron_verify_report.json", "verify report JSON output path") + outMD := fs.String("output-md", "cron_verify_report.md", "verify report Markdown output path") + failOnDrift := fs.Bool("fail-on-drift", false, "exit 3 unless the verify verdict is clean") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration cron verify --plan cron_apply_plan.json [--config host.yaml] [--source src.json] [--destination dest.json] [--fail-on-drift]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if *planPath == "" { + fmt.Fprintln(os.Stderr, "error: --plan is required") + fs.Usage() + return 1 + } + + plan, err := loadCronPlanFile(*planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if plan.FormatVersion != 1 { + fmt.Fprintf(os.Stderr, "error: %s: unsupported plan format_version %d (this build understands 1)\n", *planPath, plan.FormatVersion) + return 1 + } + + // Stale-plan gate (before any SSH). + for _, in := range []struct{ flagName, path, embedded string }{ + {"--source", *srcInv, plan.SourceSHA256}, + {"--destination", *destInv, plan.DestinationSHA256}, + } { + if in.path == "" { + continue + } + sum, err := fileSHA256(in.path) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if in.embedded == "" { + fmt.Fprintf(os.Stderr, "refused: the plan embeds no sha256 for %s, cannot prove it was built from %s (rebuild the plan)\n", in.flagName, in.path) + return exitDriftGate + } + if sum != in.embedded { + fmt.Fprintf(os.Stderr, "refused: stale plan, %s %s hashes to %s but the plan was built from %s\n", in.flagName, in.path, sum, in.embedded) + return exitDriftGate + } + } + + planSHA, err := fileSHA256(*planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + // Read live crontab, only when there are ops to verify. A + // manual-only plan needs no config and opens no SSH. + var liveCrontab string + hasOps := false + for _, op := range plan.Ops { + if op.Action == accountinventory.CronActionCreate || op.Action == accountinventory.CronActionSkip { + hasOps = true + break + } + } + + if hasOps { + ctx := context.Background() + client, err := dialCronDest(ctx, *cfgFlag) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + defer func() { _ = client.Close() }() + liveCrontab, err = cpanel.ReadCrontabRaw(ctx, client) + if err != nil { + fmt.Fprintln(os.Stderr, "error: read crontab:", err) + return 1 + } + } + + // Verify each op against the live crontab. + rep := verifyCronPlan(plan, liveCrontab) + rep.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + rep.PlanFile = *planPath + rep.PlanSHA256 = planSHA + + if err := writeCronVerifyJSON(*outJSON, rep); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := writeCronVerifyMarkdown(*outMD, rep); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + verdict := "CLEAN" + if !rep.Clean { + verdict = "NOT CLEAN" + } + fmt.Printf("cron verify: %s, %d applied, %d unchanged, %d pending, %d drift, %d manual, %d not checked\n", + verdict, rep.Summary.Applied, rep.Summary.Unchanged, rep.Summary.Pending, + rep.Summary.Drift, rep.Summary.Manual, rep.Summary.NotChecked) + fmt.Fprintf(os.Stderr, "wrote %s\n", *outJSON) + fmt.Fprintf(os.Stderr, "wrote %s\n", *outMD) + + if *failOnDrift && !rep.Clean { + fmt.Fprintln(os.Stderr, "verdict not clean and --fail-on-drift is set, exiting 3") + return exitDriftGate + } + return 0 +} + +// verifyCronPlan checks each plan op against the live crontab content. The +// live crontab is parsed and indexed by canonical REDACTED identity, so an +// entry installed in the clear (by `cron apply`) still matches its redacted +// plan line, and distinct entries can never collide on a substring. +func verifyCronPlan(plan accountinventory.CronApplyPlan, liveCrontab string) CronVerifyReport { + rep := CronVerifyReport{ + Mode: "cron-verify-report", + FormatVersion: 1, + Clean: true, + } + destSet := destCronLineSet(cpanel.ParseCrontab(liveCrontab)) + + for _, op := range plan.Ops { + res := CronVerifyOpResult{CronPlanOp: op} + switch op.Action { + case accountinventory.CronActionCreate: + if op.Line != "" && destSet[op.Line] { + res.Status = "applied" + rep.Summary.Applied++ + } else { + res.Status = "pending" + res.StatusReason = "create op entry not found in the live crontab" + rep.Summary.Pending++ + rep.Clean = false + } + case accountinventory.CronActionSkip: + if op.Line != "" && destSet[op.Line] { + res.Status = "unchanged" + rep.Summary.Unchanged++ + } else { + res.Status = "drift" + res.StatusReason = "skip op entry no longer present in the live crontab" + rep.Summary.Drift++ + rep.Clean = false + } + case accountinventory.CronActionManual: + res.Status = "manual" + rep.Summary.Manual++ + rep.Clean = false + default: + res.Status = "not_checked" + rep.Summary.NotChecked++ + } + rep.Results = append(rep.Results, res) + } + + return rep +} + +// canonicalRedactedCronJobLine renders a parsed job as its canonical REDACTED +// line (" "), byte-identical to the plan +// builder's cronJobLine on the same job. Shared by destCronLineSet (verify +// identity) and resolveCronCreateLine (source schedule-drift guard). +func canonicalRedactedCronJobLine(j cpanel.CronJob) string { + sched := j.Macro + if j.Type != "macro" { + sched = strings.Join([]string{j.Minute, j.Hour, j.DayOfMonth, j.Month, j.DayOfWeek}, " ") + } + return sched + " " + j.CommandRedacted +} + +// destCronLineSet indexes a parsed crontab by the canonical REDACTED line of +// each entry: " " for jobs, "NAME=redacted" +// for env. A plan op's redacted Line is looked up here, so an entry installed +// in the CLEAR still matches (its redacted form equals the plan line) while +// substring collisions between distinct entries cannot produce a false match. +// Disabled (commented) jobs are excluded: a create op targets an enabled job. +// +// Assurance limit (inherent, disclosed): entries whose secret was redacted +// collapse to a fixed placeholder ("NAME=[REDACTED]", or a command with +// "secure=[REDACTED]"), so this set proves the ENTRY is present but cannot +// prove the secret VALUE on the destination matches the source. The plan +// resolves the same ambiguity conservatively (it skips, never overwrites, a +// value it cannot distinguish), so a "clean" verdict means "the destination +// is consistent with the plan's decisions", not "every secret value matches". +func destCronLineSet(res cpanel.CrontabResult) map[string]bool { + set := make(map[string]bool, len(res.Jobs)+len(res.Environment)) + for _, j := range res.Jobs { + if !j.Enabled { + continue + } + set[canonicalRedactedCronJobLine(j)] = true + } + for _, e := range res.Environment { + set[e.Name+"="+e.ValueRedacted] = true + } + return set +} + +// writeCronVerifyJSON writes the machine-readable verify report. +func writeCronVerifyJSON(path string, rep CronVerifyReport) error { + b, err := json.MarshalIndent(rep, "", " ") + if err != nil { + return fmt.Errorf("marshal cron verify report: %w", err) + } + b = append(b, '\n') + dir := strings.TrimRight(path, "/") + if i := strings.LastIndex(dir, "/"); i >= 0 { + if err := os.MkdirAll(dir[:i], 0o700); err != nil { + return err + } + } + return os.WriteFile(path, b, 0o600) +} + +// cronResultsHaveRedaction reports whether any verified op carries a redacted +// secret, so the report can disclose the identity-match assurance limit. +func cronResultsHaveRedaction(results []CronVerifyOpResult) bool { + for _, r := range results { + if strings.Contains(r.Line, "[REDACTED]") { + return true + } + } + return false +} + +// writeCronVerifyMarkdown writes the human-readable verify report. +func writeCronVerifyMarkdown(path string, rep CronVerifyReport) error { + dir := strings.TrimRight(path, "/") + if i := strings.LastIndex(dir, "/"); i >= 0 { + if err := os.MkdirAll(dir[:i], 0o700); err != nil { + return err + } + } + var sb strings.Builder + fmt.Fprintf(&sb, "# Cron Verify Report\n\n") + fmt.Fprintf(&sb, "- **Plan**: %s (sha256 %s)\n", rep.PlanFile, rep.PlanSHA256) + fmt.Fprintf(&sb, "- **Generated**: %s\n", rep.GeneratedAt) + verdict := "CLEAN" + if !rep.Clean { + verdict = "NOT CLEAN" + } + fmt.Fprintf(&sb, "- **Verdict**: %s\n\n", verdict) + fmt.Fprintf(&sb, "**Summary**: %d applied, %d unchanged, %d pending, %d drift, %d manual, %d not checked\n\n", + rep.Summary.Applied, rep.Summary.Unchanged, rep.Summary.Pending, + rep.Summary.Drift, rep.Summary.Manual, rep.Summary.NotChecked) + + if cronResultsHaveRedaction(rep.Results) { + sb.WriteString("Note: entries carrying redacted secrets are matched by identity (schedule/name plus redacted command); their secret VALUES are not value-verified. A clean verdict means the destination is consistent with the plan's decisions, not that every secret value matches the source.\n\n") + } + + if len(rep.Results) == 0 { + sb.WriteString("No cron ops to verify.\n") + return os.WriteFile(path, []byte(sb.String()), 0o600) + } + + sb.WriteString("| Section | Key | Status | Note |\n|---------|-----|--------|------|\n") + for _, res := range rep.Results { + note := res.StatusReason + if note == "" { + note = res.Reason + } + fmt.Fprintf(&sb, "| %s | %s | %s | %s |\n", + res.Section, res.Key, res.Status, note) + } + sb.WriteString("\n") + + return os.WriteFile(path, []byte(sb.String()), 0o600) +} diff --git a/cmd/cpanel-self-migration/cron_verify_cmd_test.go b/cmd/cpanel-self-migration/cron_verify_cmd_test.go new file mode 100644 index 00000000..1ba75a90 --- /dev/null +++ b/cmd/cpanel-self-migration/cron_verify_cmd_test.go @@ -0,0 +1,220 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +// --- helpers ----------------------------------------------------------------- + +func readCronVerifyReport(t *testing.T, path string) CronVerifyReport { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read report: %v", err) + } + var rep CronVerifyReport + if err := json.Unmarshal(b, &rep); err != nil { + t.Fatalf("parse report: %v", err) + } + return rep +} + +// --- flag/input errors ------------------------------------------------------- + +func TestCronVerifyCmdFlagAndInputErrors(t *testing.T) { + dir := t.TempDir() + if code := runCronVerifyCmd([]string{"--definitely-not-a-flag"}); code != 2 { + t.Errorf("unknown flag: code = %d, want 2", code) + } + if code := runCronVerifyCmd([]string{}); code != 1 { + t.Errorf("missing --plan: code = %d, want 1", code) + } + if code := runCronVerifyCmd([]string{"--plan", filepath.Join(dir, "nope.json")}); code != 1 { + t.Errorf("nonexistent plan: code = %d, want 1", code) + } + badMode := filepath.Join(dir, "badmode.json") + if err := os.WriteFile(badMode, []byte(`{"mode":"dns-import-plan"}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runCronVerifyCmd([]string{"--plan", badMode}); code != 1 { + t.Errorf("wrong mode: code = %d, want 1", code) + } + badVer := filepath.Join(dir, "badver.json") + if err := os.WriteFile(badVer, []byte(`{"mode":"cron-apply-plan","format_version":2}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runCronVerifyCmd([]string{"--plan", badVer}); code != 1 { + t.Errorf("unknown format_version: code = %d, want 1", code) + } +} + +// --- stale-plan gate --------------------------------------------------------- + +func TestCronVerifyCmdStalePlanGate(t *testing.T) { + dir := t.TempDir() + src := writeCronInventory(t, dir, "src.json", "source", "acct", nil, nil) + outJSON := filepath.Join(dir, "report.json") + outMD := filepath.Join(dir, "report.md") + + t.Run("hash mismatch", func(t *testing.T) { + plan := accountinventory.CronApplyPlan{ + Mode: "cron-apply-plan", FormatVersion: 1, + SourceSHA256: "deadbeef", + } + planPath := filepath.Join(t.TempDir(), "plan.json") + if err := accountinventory.WriteCronPlanJSON(planPath, plan); err != nil { + t.Fatal(err) + } + code := runCronVerifyCmd([]string{"--plan", planPath, "--source", src, + "--output-json", outJSON, "--output-md", outMD}) + if code != 3 { + t.Fatalf("code = %d, want 3 (stale-plan refusal)", code) + } + if _, err := os.Stat(outJSON); !os.IsNotExist(err) { + t.Error("a refused verify must not write a report") + } + }) + + t.Run("no embedded hash", func(t *testing.T) { + plan := accountinventory.CronApplyPlan{ + Mode: "cron-apply-plan", FormatVersion: 1, + // SourceSHA256 intentionally empty + } + planPath := filepath.Join(t.TempDir(), "plan.json") + if err := accountinventory.WriteCronPlanJSON(planPath, plan); err != nil { + t.Fatal(err) + } + code := runCronVerifyCmd([]string{"--plan", planPath, "--source", src, + "--output-json", outJSON, "--output-md", outMD}) + if code != 3 { + t.Fatalf("code = %d, want 3 (no embedded hash)", code) + } + }) +} + +// --- manual-only plan (offline) ---------------------------------------------- + +func TestCronVerifyCmdManualOnlyOffline(t *testing.T) { + dir := t.TempDir() + plan := accountinventory.CronApplyPlan{ + Mode: "cron-apply-plan", FormatVersion: 1, + SourceUser: "acct", DestinationUser: "acct", + Ops: []accountinventory.CronPlanOp{ + {Section: accountinventory.CronSectionJobs, Action: accountinventory.CronActionManual, + Key: "disabled-job", Reason: "disabled"}, + }, + Summary: accountinventory.CronPlanSummary{Manual: 1}, + } + planPath := filepath.Join(dir, "plan.json") + if err := accountinventory.WriteCronPlanJSON(planPath, plan); err != nil { + t.Fatal(err) + } + + outJSON := filepath.Join(dir, "report.json") + outMD := filepath.Join(dir, "report.md") + + // A manual-only plan needs no config and opens no SSH. + cwd, _ := os.Getwd() + empty := t.TempDir() + _ = os.Chdir(empty) + defer func() { _ = os.Chdir(cwd) }() + + code := runCronVerifyCmd([]string{ + "--plan", planPath, + "--output-json", outJSON, "--output-md", outMD, + }) + if code != 0 { + t.Fatalf("code = %d, want 0", code) + } + + rep := readCronVerifyReport(t, outJSON) + if rep.Summary.Manual != 1 { + t.Errorf("summary = %+v, want 1 manual", rep.Summary) + } + // Manual ops make the verdict not-clean. + if rep.Clean { + t.Error("manual ops should make the verdict not clean") + } +} + +// --- E2E verify after apply -------------------------------------------------- + +func TestCronVerifyCmdEndToEndAfterApply(t *testing.T) { + cfgPath, srcHome, destHome := setupCronServer(t) + dir := t.TempDir() + planPath := buildCronTestPlan(t, dir) + setCronStubState(t, srcHome, "0 3 * * * /usr/bin/true\nMAILTO=test@example.com\n") + setCronStubState(t, destHome, "# existing\n") + + // Apply first. + applyJSON := filepath.Join(dir, "apply_report.json") + if code := runCronApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", filepath.Join(dir, "backup.json"), + "--output-json", applyJSON, + }); code != 0 { + t.Fatalf("apply: code = %d", code) + } + + // Now verify: all create ops should show as applied. + outJSON := filepath.Join(dir, "verify_report.json") + outMD := filepath.Join(dir, "verify_report.md") + code := runCronVerifyCmd([]string{ + "--plan", planPath, "--config", cfgPath, + "--output-json", outJSON, "--output-md", outMD, + }) + if code != 0 { + t.Fatalf("verify: code = %d, want 0", code) + } + + rep := readCronVerifyReport(t, outJSON) + if rep.Summary.Applied != 2 { // 1 job + 1 env + t.Errorf("summary = %+v, want 2 applied", rep.Summary) + } + if !rep.Clean { + t.Error("post-apply verify should be clean") + } + if _, err := os.Stat(outMD); err != nil { + t.Error("markdown report missing") + } +} + +// --- fail-on-drift ----------------------------------------------------------- + +func TestCronVerifyCmdFailOnDrift(t *testing.T) { + cfgPath, _, destHome := setupCronServer(t) + dir := t.TempDir() + planPath := buildCronTestPlan(t, dir) + // Destination crontab does NOT contain the planned lines = pending/drift. + // Verify is dest-only, so the source server stays unused here. + setCronStubState(t, destHome, "# empty destination\n") + + outJSON := filepath.Join(dir, "verify_report.json") + outMD := filepath.Join(dir, "verify_report.md") + + // Without --fail-on-drift: exit 0 even with pending ops. + code := runCronVerifyCmd([]string{ + "--plan", planPath, "--config", cfgPath, + "--output-json", outJSON, "--output-md", outMD, + }) + if code != 0 { + t.Fatalf("verify without --fail-on-drift: code = %d, want 0", code) + } + + // With --fail-on-drift: exit 3. + code = runCronVerifyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--fail-on-drift", + "--output-json", outJSON, "--output-md", outMD, + }) + if code != 3 { + t.Fatalf("verify with --fail-on-drift: code = %d, want 3", code) + } +} diff --git a/cmd/cpanel-self-migration/dispatch_test.go b/cmd/cpanel-self-migration/dispatch_test.go new file mode 100644 index 00000000..7b15972c --- /dev/null +++ b/cmd/cpanel-self-migration/dispatch_test.go @@ -0,0 +1,102 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +// Dispatch contract: `inventory` with a missing or unknown subcommand must +// be refused with exit 2 BEFORE the migration flag parsing ever runs. +// Before this guard existed, `inventory polcy` (typo) and a bare +// `inventory` fell through to the migration flow and, with a resolvable +// host.yaml, started a full migration dry-run - the same footgun class the +// `dns` namespace already refuses with exit 2 (PR 6C). +// +// These tests exercise the REAL main() dispatch: TestMain re-execs this +// test binary with CPSM_DISPATCH_TEST_CHILD=1 so every os.Exit happens in +// a child process. The child runs in an empty working directory, so no +// host.yaml is ever resolvable even if the fall-through regressed. + +import ( + "errors" + "os" + "os/exec" + "strings" + "testing" +) + +func TestMain(m *testing.M) { + if os.Getenv("CPSM_DISPATCH_TEST_CHILD") == "1" { + main() // every dispatch path below ends in os.Exit + return + } + os.Exit(m.Run()) +} + +// runDispatchChild re-execs this test binary as `cpanel-self-migration +// ` in a fresh temp dir and returns its exit code and stderr. +// +// The child inherits os.Environ(): safe today because no test in this +// package uses t.Parallel(), so the PATH-stubbing tests (stub uapi/cpapi2 +// via t.Setenv) can never interleave with a re-exec. If t.Parallel() is +// ever introduced here, pin PATH explicitly in cmd.Env. +func runDispatchChild(t *testing.T, args ...string) (int, string) { + t.Helper() + cmd := exec.Command(os.Args[0], args...) + cmd.Dir = t.TempDir() + cmd.Env = append(os.Environ(), "CPSM_DISPATCH_TEST_CHILD=1") + var stderr strings.Builder + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + return 0, stderr.String() + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("re-exec %v: %v", args, err) + } + return exitErr.ExitCode(), stderr.String() +} + +func TestDispatchInventoryRefusesUnknownAndBare(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"typo subcommand", []string{"inventory", "polcy"}}, + {"bare inventory", []string{"inventory"}}, + {"help is not a subcommand", []string{"inventory", "--help"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, stderr := runDispatchChild(t, tc.args...) + if code != 2 { + t.Fatalf("exit code = %d, want 2; stderr:\n%s", code, stderr) + } + if !strings.Contains(stderr, "usage: cpanel-self-migration inventory") { + t.Errorf("stderr missing inventory usage line:\n%s", stderr) + } + // Reaching the migration flow would surface its config + // resolution error instead of the dispatch usage. + if strings.Contains(stderr, "host.yaml") || strings.Contains(stderr, "--config") { + t.Errorf("stderr suggests the migration flow ran:\n%s", stderr) + } + }) + } +} + +// Known subcommands must still route: `inventory diff` without flags is +// the subcommand's own input error (exit 1 + its message), NOT the +// dispatch usage (exit 2) and NOT the migration flow. +func TestDispatchInventoryKnownSubcommandStillRoutes(t *testing.T) { + code, stderr := runDispatchChild(t, "inventory", "diff") + if code != 1 { + t.Fatalf("exit code = %d, want 1 (diff's own input error); stderr:\n%s", code, stderr) + } + if !strings.Contains(stderr, "--source and --destination are required") { + t.Errorf("stderr is not the diff subcommand's error:\n%s", stderr) + } +} + +// The write-side namespaces (dns/cron/email) are Batch-D and are not +// wired yet: their dispatch-refusal locks land with those commands. The +// read-only `inventory` and the local `migration` session-governance +// namespaces are wired; their dispatch contracts are asserted here. diff --git a/cmd/cpanel-self-migration/dns_apply_cmd.go b/cmd/cpanel-self-migration/dns_apply_cmd.go new file mode 100644 index 00000000..6f110b6a --- /dev/null +++ b/cmd/cpanel-self-migration/dns_apply_cmd.go @@ -0,0 +1,1072 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/config" + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "github.com/tis24dev/cPanel_self-migration/internal/sshx" +) + +// runDNSApplyCmd implements `cpanel-self-migration dns apply`: the DNS +// config writer (PR 6D). It consumes an offline dns_import_plan.json +// and writes DNS records onto the DESTINATION account via +// DNS::mass_edit_zone with serial guard (optimistic locking). +// +// v2 implements `add` and `replace` (atomic remove+add in one +// mass_edit_zone call). `manual` and `skip` ops are non-writable. +// +// House contract (docs/dev/PR6D_DNS_APPLY_DESIGN.md, PR_DNSV2_REPLACE_DESIGN.md): +// - without --yes-apply-writes: fully offline preview, ZERO connections; +// - backup-or-nothing before the first write, bidirectionally paired +// with the report; +// - serial guard: stale serial -> all ops for that zone get +// refused_precondition; +// - replace preconditions: already_present, drift, growth drift, missing +// rrset, empty DestinationValues -> refused_precondition; +// - unconditional per-op verify-after (re-fetch zone, match by +// type+name+data; for replace: old values must also be absent); +// - --rollback : report-driven inverse ops, removes applied +// adds by line index, restores applied replaces from backup values. +// +// Exit codes: 0 ok; 1 input/runtime/write failure (report still written +// when the run got that far); 2 flags; 3 gated refusal (one or more +// refused_precondition ops). +func runDNSApplyCmd(args []string) int { + fs := flag.NewFlagSet("dns apply", flag.ContinueOnError) + planPath := fs.String("plan", "", "path to dns_import_plan.json (required unless --rollback)") + cfgFlag := fs.String("config", "", "path to host.yaml (default: configs/host.yaml or host.yaml)") + yes := fs.Bool("yes-apply-writes", false, "actually write to the DESTINATION (default: fully offline preview, zero connections)") + rollbackPath := fs.String("rollback", "", "path to a dns apply backup JSON: roll back that run instead of applying") + reportFlag := fs.String("report", "", "with --rollback: explicit path of the paired apply report (overrides the backup's recorded pairing)") + acceptReportLoss := fs.Bool("accept-report-loss", false, "with --rollback: proceed WITHOUT the paired report, documented degradation: ALL ops become MANUAL") + backupFlag := fs.String("backup", "", "pre-write backup path (default: dns_backup_.json in the report directory)") + outJSON := fs.String("output-json", "dns_apply_report.json", "report JSON path") + outMD := fs.String("output-md", "", "report Markdown path (default: derived from --output-json)") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration dns apply --plan dns_import_plan.json [--yes-apply-writes] [--config host.yaml] [--backup PATH]") + fmt.Fprintln(os.Stderr, " cpanel-self-migration dns apply --rollback dns_backup_....json [--report REPORT.json|--accept-report-loss] [--yes-apply-writes]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + + if *rollbackPath != "" { + if *planPath != "" { + fmt.Fprintln(os.Stderr, "error: --plan and --rollback are mutually exclusive") + return 2 + } + return runDNSRollback(*rollbackPath, *reportFlag, *acceptReportLoss, *yes, *cfgFlag, *outJSON, *outMD) + } + if *reportFlag != "" || *acceptReportLoss { + fmt.Fprintln(os.Stderr, "error: --report/--accept-report-loss only apply to --rollback") + return 2 + } + if *planPath == "" { + fmt.Fprintln(os.Stderr, "error: --plan is required") + fs.Usage() + return 1 + } + + plan, err := loadDNSPlanFile(*planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if plan.FormatVersion != 1 { + fmt.Fprintf(os.Stderr, "error: %s: unsupported plan format_version %d (this build understands 1)\n", *planPath, plan.FormatVersion) + return 1 + } + + if !*yes { + printDNSApplyDryRun(plan) + return 0 + } + if *outMD == "" { + *outMD = deriveMDPath(*outJSON) + } + return runDNSApplyWrites(plan, *planPath, *cfgFlag, *backupFlag, *outJSON, *outMD) +} + +// printDNSApplyDryRun is the offline preview: no config, no SSH, no +// artifact files. +func printDNSApplyDryRun(plan accountinventory.DNSPlan) { + fmt.Println("dns apply, DRY-RUN (fully offline: no connection was opened, nothing was written).") + fmt.Println("NOTE: this preview renders the PLAN-recorded ops, not the live zone state -") + fmt.Println("the live preview is `dns verify`.") + fmt.Println() + writes := 0 + for _, z := range plan.Zones { + for _, op := range z.Ops { + switch op.Action { + case accountinventory.ActionAdd: + fmt.Printf(" [%s] add %s %s (%d record(s))\n", z.Zone, op.Type, op.Name, len(op.Records)) + writes++ + case accountinventory.ActionReplace: + fmt.Printf(" [%s] replace %s %s (%d record(s))\n", z.Zone, op.Type, op.Name, len(op.Records)) + writes++ + case accountinventory.ActionManual: + fmt.Printf(" [%s] manual %s %s, %s\n", z.Zone, op.Type, op.Name, op.Reason) + } + } + } + if writes == 0 { + fmt.Println(" (no writable ops in this plan)") + } + fmt.Printf("\nplan summary: %d add, %d replace, %d manual, %d skip, %d informational\n", + plan.Summary.Add, plan.Summary.Replace, plan.Summary.Manual, + plan.Summary.Skip, plan.Summary.Informational) + fmt.Println("to apply: re-run with --yes-apply-writes") +} + +// dnsCanonToRelative converts a canonical name (absolute FQDN with trailing +// dot) to the dname form mass_edit_zone expects: the fully-qualified zone name +// for the apex, otherwise the ".zone." suffix stripped. +// +// The apex MUST be the FQDN, NOT "@": mass_edit_zone REJECTS "@" as the apex +// shorthand and fails the WHOLE atomic batch with status=0 "The request failed +// (Error ID ...)". This was the real N1 dogfooding failure, the apex SPF +// replace poisoned an otherwise-valid batch. Reproduced byte-for-byte on a live +// host: add dname="@" -> status=0; add dname="." -> status=1. +func dnsCanonToRelative(canonical, zone string) string { + zDot := strings.ToLower(zone) + "." + c := strings.ToLower(canonical) + if c == zDot { + return zDot // apex -> FQDN (mass_edit_zone rejects "@") + } + suffix := "." + zDot + if strings.HasSuffix(c, suffix) { + return strings.TrimSuffix(c, suffix) + } + return canonical +} + +// dialDNSDest resolves the config and dials the DESTINATION. +func dialDNSDest(ctx context.Context, cfgFlag string) (*sshx.Client, error) { + path, alternates, err := resolveConfigPath(cfgFlag) + if err != nil { + return nil, err + } + for _, alt := range alternates { + fmt.Fprintf(os.Stderr, "note: multiple host.yaml candidates, using %s (ignoring %s)\n", path, alt) + } + cfg, err := config.Load(path) + if err != nil { + return nil, err + } + if !cfg.DestConfigured() { + return nil, fmt.Errorf("the dns commands need the DESTINATION host configured in %s", path) + } + client, err := sshx.DialDest(ctx, cfg, "") + if err != nil { + return nil, fmt.Errorf("dial destination: %w", err) + } + return client, nil +} + +// writableZones returns the zones that have at least one add or replace op. +func writableZones(plan accountinventory.DNSPlan) []string { + seen := map[string]bool{} + var zones []string + for _, z := range plan.Zones { + for _, op := range z.Ops { + if (op.Action == accountinventory.ActionAdd || op.Action == accountinventory.ActionReplace) && !seen[z.Zone] { + seen[z.Zone] = true + zones = append(zones, z.Zone) + } + } + } + sort.Strings(zones) + return zones +} + +// runDNSApplyWrites is the write path: fetch zones -> backup-or-nothing +// -> mass_edit_zone per zone -> verify-after -> report. +func runDNSApplyWrites(plan accountinventory.DNSPlan, planPath, cfgFlag, backupFlag, outJSON, outMD string) int { + planSHA, err := fileSHA256(planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + wZones := writableZones(plan) + + ctx := context.Background() + var client *sshx.Client + if len(wZones) > 0 { + client, err = dialDNSDest(ctx, cfgFlag) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + defer func() { _ = client.Close() }() + } + + // Fetch the live state for every writable zone: records + raw + serial. + type zoneLiveState struct { + records []cpanel.DNSRecord + raw []byte + serial string + } + liveState := map[string]zoneLiveState{} + for _, zone := range wZones { + records, raw, err := cpanel.FetchDNSZoneRaw(ctx, client, zone) + if err != nil { + fmt.Fprintf(os.Stderr, "error: fetch zone %s: %v\n", zone, err) + return 1 + } + serial, err := cpanel.ExtractSOASerial(raw) + if err != nil { + fmt.Fprintf(os.Stderr, "error: extract serial for %s: %v\n", zone, err) + return 1 + } + liveState[zone] = zoneLiveState{records: records, raw: raw, serial: serial} + } + + report := accountinventory.DNSApplyReport{ + Mode: "dns-apply-report", FormatVersion: 1, RunMode: "apply", + PlanFile: planPath, PlanSHA256: planSHA, + } + + // Backup-or-nothing: written BEFORE the first write. + if len(wZones) > 0 { + backupPath := backupFlag + if backupPath == "" { + backupPath = filepath.Join(filepath.Dir(outJSON), + fmt.Sprintf("dns_backup_%s.json", time.Now().UTC().Format("20060102-150405"))) + } + var backupZones []accountinventory.DNSBackupZone + for _, zone := range wZones { + ls := liveState[zone] + backupZones = append(backupZones, accountinventory.DNSBackupZone{ + Zone: zone, + Records: ls.records, + Raw: json.RawMessage(ls.raw), + Serial: ls.serial, + }) + } + backup := accountinventory.DNSApplyBackup{ + Mode: "dns-apply-backup", FormatVersion: 1, + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + PlanFile: planPath, PlanSHA256: planSHA, + ReportFile: outJSON, + Zones: backupZones, + } + if err := accountinventory.WriteDNSApplyBackupJSON(backupPath, backup); err != nil { + fmt.Fprintln(os.Stderr, "error: backup-or-nothing, backup write failed, NOTHING was written:", err) + return 1 + } + backupSHA, err := fileSHA256(backupPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error: backup-or-nothing, cannot hash the backup, NOTHING was written:", err) + return 1 + } + report.BackupFile, report.BackupSHA256 = backupPath, backupSHA + fmt.Fprintf(os.Stderr, "wrote %s (pre-write backup)\n", backupPath) + } else { + report.BackupNote = "no write was decided (every op skipped or manual), nothing to back up" + } + + // Process each zone in the plan. + for _, pz := range plan.Zones { + zr := accountinventory.DNSApplyZoneResult{Zone: pz.Zone} + + // Classify each op. + type pendingWrite struct { + op accountinventory.PlanOp + idx int + action string // "add" or "replace" + } + var writes []pendingWrite + + for _, op := range pz.Ops { + res := accountinventory.DNSApplyOpResult{PlanOp: op} + switch op.Action { + case accountinventory.ActionSkip: + res.Status = accountinventory.DNSOpSkipped + case accountinventory.ActionManual: + res.Status = accountinventory.DNSOpManual + case accountinventory.ActionReplace: + res.Status = accountinventory.DNSOpApplied // placeholder, overwritten below + writes = append(writes, pendingWrite{op: op, idx: len(zr.Ops), action: "replace"}) + case accountinventory.ActionAdd: + res.Status = accountinventory.DNSOpApplied // placeholder, overwritten below + writes = append(writes, pendingWrite{op: op, idx: len(zr.Ops), action: "add"}) + default: + res.Status = accountinventory.DNSOpFailed + res.StatusReason = fmt.Sprintf("unknown plan action %q, malformed or hand-edited plan", op.Action) + } + zr.Ops = append(zr.Ops, res) + } + + // If this zone has writable ops, process them. + if len(writes) > 0 { + ls, ok := liveState[pz.Zone] + if !ok { + for _, w := range writes { + zr.Ops[w.idx].Status = accountinventory.DNSOpFailed + zr.Ops[w.idx].StatusReason = "zone live state not fetched" + } + } else { + // Resolve replace preconditions and collect batch params. + var removeLines []int + var addRecords []cpanel.MassEditAddRecord + hasReplace := false + + for i, w := range writes { + if w.action != "replace" { + // Add ops: just collect records. + for _, rec := range w.op.Records { + addRecords = append(addRecords, cpanel.MassEditAddRecord{ + DName: dnsCanonToRelative(rec.Name, pz.Zone), + TTL: rec.TTL, + RecordType: rec.Type, + Data: rec.Data, + }) + } + continue + } + hasReplace = true + + // Replace precondition: check the live zone. + status, reason, lines := dnsReplacePrecondition(w.op, ls.records, pz.Zone) + if status == accountinventory.DNSOpSkipped { + // Already present, no write needed. + zr.Ops[w.idx].Status = status + zr.Ops[w.idx].StatusReason = reason + writes[i].action = "" // mark as resolved + continue + } + if status == accountinventory.DNSOpRefused { + zr.Ops[w.idx].Status = status + zr.Ops[w.idx].StatusReason = reason + writes[i].action = "" // mark as resolved + continue + } + + // Precondition met: queue remove (old lines) + add (new records). + removeLines = append(removeLines, lines...) + for _, rec := range w.op.Records { + addRecords = append(addRecords, cpanel.MassEditAddRecord{ + DName: dnsCanonToRelative(rec.Name, pz.Zone), + TTL: rec.TTL, + RecordType: rec.Type, + Data: rec.Data, + }) + } + } + + // Filter to only the active writes. + var activeWrites []pendingWrite + for _, w := range writes { + if w.action != "" { + activeWrites = append(activeWrites, w) + } + } + + for _, w := range activeWrites { + if len(w.op.Records) == 0 { + zr.Ops[w.idx].Status = accountinventory.DNSOpFailed + zr.Ops[w.idx].StatusReason = "op has no records, malformed or hand-edited plan" + } + } + + if len(activeWrites) > 0 && len(addRecords) > 0 { + var result cpanel.MassEditResult + var writeErr error + + if hasReplace || len(removeLines) > 0 { + result, writeErr = cpanel.MassEditZoneBatch(ctx, client, pz.Zone, ls.serial, removeLines, addRecords) + } else { + result, writeErr = cpanel.MassEditZoneAdd(ctx, client, pz.Zone, ls.serial, addRecords) + } + + if writeErr != nil { + status := accountinventory.DNSOpFailed + reason := writeErr.Error() + if cpanel.IsStaleSerialError(writeErr) { + status = accountinventory.DNSOpRefused + reason = "stale serial, zone was modified since fetch" + } + for _, w := range activeWrites { + zr.Ops[w.idx].Status = status + zr.Ops[w.idx].StatusReason = reason + } + } else { + zr.NewSerial = result.NewSerial + + freshRecords, _, fetchErr := cpanel.FetchDNSZoneRaw(ctx, client, pz.Zone) + if fetchErr != nil { + for _, w := range activeWrites { + zr.Ops[w.idx].Status = accountinventory.DNSOpFailed + zr.Ops[w.idx].StatusReason = "verify-after re-fetch failed: " + fetchErr.Error() + } + } else { + for _, w := range activeWrites { + // An op already flagged terminal (e.g. an + // empty-records op marked Failed above) must not + // be resurrected to applied by a sibling op's + // successful write. dnsVerifyAddOpPresent returns + // true vacuously for an empty Records slice, so + // skip such ops explicitly. + if zr.Ops[w.idx].Status == accountinventory.DNSOpFailed { + continue + } + if !dnsVerifyAddOpPresent(w.op, freshRecords, pz.Zone) { + zr.Ops[w.idx].Status = accountinventory.DNSOpFailed + zr.Ops[w.idx].StatusReason = "write reported success but the records are not observable in the fresh zone" + continue + } + if w.action == "replace" && dnsOldValuesStillPresent(w.op, freshRecords, pz.Zone) { + zr.Ops[w.idx].Status = accountinventory.DNSOpFailed + zr.Ops[w.idx].StatusReason = "new values present but old values still in zone, remove may have failed" + continue + } + zr.Ops[w.idx].Status = accountinventory.DNSOpApplied + } + } + } + } + + } + } + + report.Zones = append(report.Zones, zr) + } + + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Summary = accountinventory.SummarizeDNSResults(report.Zones) + return finishDNSReport(report, outJSON, outMD) +} + +// dnsVerifyAddOpPresent checks that every planned record in an add op +// is present in the fresh zone (match by type+name+data). +func dnsVerifyAddOpPresent(op accountinventory.PlanOp, fresh []cpanel.DNSRecord, zone string) bool { + for _, rec := range op.Records { + if !dnsRecordPresent(rec, fresh, zone) { + return false + } + } + return true +} + +// dnsCanonLiveName canonicalizes a DNS name from a parse_zone response +// (which can be "@", relative, or absolute FQDN) to an absolute FQDN. +func dnsCanonLiveName(name, zone string) string { + n := strings.ToLower(name) + if n == "@" || n == "" { + return strings.ToLower(zone) + "." + } + if strings.HasSuffix(n, ".") { + return n + } + return n + "." + strings.ToLower(zone) + "." +} + +// dnsRecordPresent checks if a single planned record exists in the +// live zone. +func dnsRecordPresent(rec accountinventory.PlanRecord, fresh []cpanel.DNSRecord, zone string) bool { + canonName := strings.ToLower(rec.Name) + for _, f := range fresh { + if !strings.EqualFold(f.Type, rec.Type) { + continue + } + if dnsCanonLiveName(f.Name, zone) != canonName { + continue + } + if dnsDataMatch(rec, f) { + return true + } + } + return false +} + +// dnsDataMatch compares the planned record's Data with the live +// record's type-specific field. +func dnsDataMatch(planned accountinventory.PlanRecord, live cpanel.DNSRecord) bool { + switch planned.Type { + case "A", "AAAA": + if len(planned.Data) < 1 { + return false + } + return strings.EqualFold(planned.Data[0], live.Address) + case "CNAME": + if len(planned.Data) < 1 { + return false + } + pTarget := strings.ToLower(planned.Data[0]) + lTarget := strings.ToLower(live.Target) + // Normalize trailing dots for comparison. + pTarget = strings.TrimSuffix(pTarget, ".") + lTarget = strings.TrimSuffix(lTarget, ".") + return pTarget == lTarget + case "MX": + if len(planned.Data) < 2 { + return false + } + if fmt.Sprintf("%d", live.Priority) != planned.Data[0] { + return false + } + pExchange := strings.TrimSuffix(strings.ToLower(planned.Data[1]), ".") + lExchange := strings.TrimSuffix(strings.ToLower(live.Exchange), ".") + return pExchange == lExchange + case "TXT": + // Planned Data is pre-split segments; live TxtData is joined. + joined := strings.Join(planned.Data, "") + return joined == live.TxtData + } + return false +} + +// dnsReplacePrecondition checks whether the live zone state allows the +// replace op to proceed. Returns (status, reason, lineIndexes). +// - skipped + "already_present": live already has the desired values +// - refused_precondition: live has neither desired nor plan-time dest values +// - "" (empty status): precondition met, proceed with remove+add +func dnsReplacePrecondition(op accountinventory.PlanOp, live []cpanel.DNSRecord, zone string) (string, string, []int) { + canonName := strings.ToLower(op.Name) + + // Collect live records matching type+name. + var matching []cpanel.DNSRecord + for _, f := range live { + if !strings.EqualFold(f.Type, op.Type) { + continue + } + if dnsCanonLiveName(f.Name, zone) == canonName { + matching = append(matching, f) + } + } + + if len(matching) == 0 { + return accountinventory.DNSOpRefused, "rrset not found on destination, cannot replace what is absent", nil + } + + if len(op.DestinationValues) == 0 { + return accountinventory.DNSOpRefused, "plan has no destination values for this replace op, re-plan required", nil + } + + if len(matching) != len(op.DestinationValues) { + return accountinventory.DNSOpRefused, + fmt.Sprintf("rrset has %d record(s) but plan expected %d, drift detected (records added or removed since plan-time), re-plan required", + len(matching), len(op.DestinationValues)), + nil + } + + // Check if ALL desired records are already present. + allDesiredPresent := true + for _, rec := range op.Records { + if !dnsRecordPresent(rec, matching, zone) { + allDesiredPresent = false + break + } + } + if allDesiredPresent { + return accountinventory.DNSOpSkipped, "already_present: destination already has the desired values", nil + } + + // Check if live values match plan-time DestinationValues, precondition. + var lines []int + usedLines := map[int]bool{} + for _, dv := range op.DestinationValues { + found := false + for _, f := range matching { + if usedLines[f.Line] { + continue + } + if dnsLiveMatchesCanonValue(f, dv) { + lines = append(lines, f.Line) + usedLines[f.Line] = true + found = true + break + } + } + if !found { + return accountinventory.DNSOpRefused, + fmt.Sprintf("plan-time destination value %q not found in the live zone, drift detected, re-plan required", dv), + nil + } + } + return "", "", lines +} + +// dnsLiveMatchesCanonValue checks if a live DNS record matches a +// canonical plan value (as produced by planValue in dnsplan.go). +func dnsLiveMatchesCanonValue(live cpanel.DNSRecord, canonValue string) bool { + switch live.Type { + case "A", "AAAA": + return strings.EqualFold(live.Address, canonValue) + case "CNAME": + lTarget := strings.TrimSuffix(strings.ToLower(live.Target), ".") + cTarget := strings.TrimSuffix(strings.ToLower(canonValue), ".") + return lTarget == cTarget + case "MX": + parts := strings.SplitN(canonValue, "\x00", 2) + if len(parts) != 2 { + return false + } + if fmt.Sprintf("%d", live.Priority) != parts[0] { + return false + } + lExch := strings.TrimSuffix(strings.ToLower(live.Exchange), ".") + cExch := strings.TrimSuffix(strings.ToLower(parts[1]), ".") + return lExch == cExch + case "TXT": + return live.TxtData == canonValue + } + return false +} + +// dnsOldValuesStillPresent checks whether any plan-time destination +// values (the pre-replace state) are still observable in the fresh zone. +// Used by verify-after to detect a failed remove in a replace op. +func dnsOldValuesStillPresent(op accountinventory.PlanOp, fresh []cpanel.DNSRecord, zone string) bool { + canonName := strings.ToLower(op.Name) + for _, dv := range op.DestinationValues { + for _, f := range fresh { + if !strings.EqualFold(f.Type, op.Type) { + continue + } + if dnsCanonLiveName(f.Name, zone) != canonName { + continue + } + if dnsLiveMatchesCanonValue(f, dv) { + return true + } + } + } + return false +} + +// finishDNSReport writes both report artifacts and translates the +// summary into the exit code. +func finishDNSReport(report accountinventory.DNSApplyReport, outJSON, outMD string) int { + if err := accountinventory.WriteDNSApplyReportJSON(outJSON, report); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := accountinventory.WriteDNSApplyReportMarkdown(outMD, report); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + s := report.Summary + fmt.Printf("dns %s: %d applied, %d skipped, %d manual, %d failed, %d refused\n", + report.RunMode, s.Applied, s.Skipped, s.Manual, s.Failed, s.Refused) + fmt.Fprintf(os.Stderr, "wrote %s\nwrote %s\n", outJSON, outMD) + switch { + case s.Failed > 0: + fmt.Fprintln(os.Stderr, "one or more ops FAILED, exiting 1 (see the report)") + return 1 + case s.Refused > 0: + fmt.Fprintln(os.Stderr, "one or more ops were refused by the serial guard, exiting 3 (re-fetch and retry)") + return exitDriftGate + } + return 0 +} + +// --- rollback ---------------------------------------------------------------- + +// loadDNSBackupFile reads and minimally validates a DNS apply backup. +func loadDNSBackupFile(path string) (accountinventory.DNSApplyBackup, error) { + var b accountinventory.DNSApplyBackup + raw, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return b, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(raw, &b); err != nil { + return b, fmt.Errorf("parse %s: %w", path, err) + } + if b.Mode != "dns-apply-backup" { + return b, fmt.Errorf("%s: not a DNS apply backup (mode %q)", path, b.Mode) + } + return b, nil +} + +// loadDNSReportFile reads and minimally validates a DNS apply report. +func loadDNSReportFile(path string) (accountinventory.DNSApplyReport, error) { + var r accountinventory.DNSApplyReport + if path == "" { + return r, fmt.Errorf("the backup records no paired report path") + } + raw, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return r, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(raw, &r); err != nil { + return r, fmt.Errorf("parse %s: %w", path, err) + } + if r.Mode != "dns-apply-report" { + return r, fmt.Errorf("%s: not a DNS apply report (mode %q)", path, r.Mode) + } + return r, nil +} + +// runDNSRollback drives `dns apply --rollback `. +func runDNSRollback(backupPath, reportFlag string, acceptLoss, yes bool, cfgFlag, outJSON, outMD string) int { + backup, err := loadDNSBackupFile(backupPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if backup.FormatVersion != 1 { + fmt.Fprintf(os.Stderr, "error: %s: unsupported backup format_version %d\n", backupPath, backup.FormatVersion) + return 1 + } + if outMD == "" { + outMD = deriveMDPath(outJSON) + } + + reportPath := reportFlag + if reportPath == "" { + reportPath = backup.ReportFile + } + + applyReport, reportErr := loadDNSReportFile(reportPath) + if reportErr != nil && !acceptLoss { + fmt.Fprintf(os.Stderr, "error: the paired apply report is a REQUIRED rollback input and could not be read (%v).\n", reportErr) + fmt.Fprintln(os.Stderr, "Pass --report if it lives elsewhere, or --accept-report-loss for the documented degradation (ALL ops become MANUAL).") + return 1 + } + + isDegraded := reportErr != nil && acceptLoss + if isDegraded { + fmt.Fprintf(os.Stderr, "warning: paired report unavailable (%v), DEGRADED rollback: ALL ops are MANUAL, no writes\n", reportErr) + } + + // Collect zones that have applied adds or replaces in the report. + type rollbackTarget struct { + zone string + action string // original action: "add" or "replace" + opType string + opName string + records []accountinventory.PlanRecord // records written by apply (to remove) + oldRecords []accountinventory.PlanRecord // pre-apply records (to restore, replace only) + } + var targets []rollbackTarget + if !isDegraded { + for _, zr := range applyReport.Zones { + for _, op := range zr.Ops { + if op.Status != accountinventory.DNSOpApplied { + continue + } + switch op.Action { + case accountinventory.ActionAdd: + targets = append(targets, rollbackTarget{ + zone: zr.Zone, + action: "add", + opType: op.Type, + opName: op.Name, + records: op.Records, + }) + case accountinventory.ActionReplace: + old := dnsBackupRecordsForOp(backup, zr.Zone, op.Type, op.Name) + targets = append(targets, rollbackTarget{ + zone: zr.Zone, + action: "replace", + opType: op.Type, + opName: op.Name, + records: op.Records, + oldRecords: old, + }) + } + } + } + } + + if !yes { + fmt.Println("dns rollback, DRY-RUN (fully offline: no connection was opened, nothing was written).") + if isDegraded { + fmt.Println(" DEGRADED: all zones are MANUAL (no report available to identify applied ops)") + } else { + for _, t := range targets { + if t.action == "replace" { + fmt.Printf(" restore [%s] %s %s (remove %d new, add %d old)\n", t.zone, t.opType, t.opName, len(t.records), len(t.oldRecords)) + } else { + fmt.Printf(" remove [%s] %s %s (%d record(s))\n", t.zone, t.opType, t.opName, len(t.records)) + } + } + if len(targets) == 0 { + fmt.Println(" (nothing to roll back: no applied ops in the report)") + } + } + fmt.Println("to roll back: re-run with --yes-apply-writes") + return 0 + } + + // Build the rollback report. + report := accountinventory.DNSApplyReport{ + Mode: "dns-apply-report", FormatVersion: 1, RunMode: "rollback", + BackupFile: backupPath, + } + if sha, err := fileSHA256(backupPath); err == nil { + report.BackupSHA256 = sha + } + + if isDegraded { + // All zones become manual. + for _, bz := range backup.Zones { + zr := accountinventory.DNSApplyZoneResult{Zone: bz.Zone} + zr.Ops = append(zr.Ops, accountinventory.DNSApplyOpResult{ + PlanOp: accountinventory.PlanOp{ + Action: accountinventory.ActionManual, + Reason: "degraded rollback, no report available", + }, + Status: accountinventory.DNSOpManual, + }) + report.Zones = append(report.Zones, zr) + } + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Summary = accountinventory.SummarizeDNSResults(report.Zones) + return finishDNSReport(report, outJSON, outMD) + } + + if len(targets) == 0 { + report.BackupNote = "no applied ops found in the report, nothing to roll back" + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Summary = accountinventory.SummarizeDNSResults(nil) + return finishDNSReport(report, outJSON, outMD) + } + + ctx := context.Background() + client, err := dialDNSDest(ctx, cfgFlag) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + defer func() { _ = client.Close() }() + + // Group targets by zone. + targetsByZone := map[string][]rollbackTarget{} + var zoneOrder []string + for _, t := range targets { + if _, ok := targetsByZone[t.zone]; !ok { + zoneOrder = append(zoneOrder, t.zone) + } + targetsByZone[t.zone] = append(targetsByZone[t.zone], t) + } + + for _, zone := range zoneOrder { + zt := targetsByZone[zone] + zr := accountinventory.DNSApplyZoneResult{Zone: zone} + + // Single fetch: records for line-index matching + raw for serial. + records, raw, fetchErr := cpanel.FetchDNSZoneRaw(ctx, client, zone) + if fetchErr != nil { + for _, t := range zt { + zr.Ops = append(zr.Ops, accountinventory.DNSApplyOpResult{ + PlanOp: accountinventory.PlanOp{Action: t.opType, Type: t.opType, Name: t.opName}, + Status: accountinventory.DNSOpFailed, + StatusReason: "rollback re-fetch failed: " + fetchErr.Error(), + }) + } + report.Zones = append(report.Zones, zr) + continue + } + serial, serialErr := cpanel.ExtractSOASerial(raw) + if serialErr != nil { + for _, t := range zt { + zr.Ops = append(zr.Ops, accountinventory.DNSApplyOpResult{ + PlanOp: accountinventory.PlanOp{Action: t.opType, Type: t.opType, Name: t.opName}, + Status: accountinventory.DNSOpFailed, + StatusReason: "rollback serial extraction failed: " + serialErr.Error(), + }) + } + report.Zones = append(report.Zones, zr) + continue + } + + // For each target, find the line indexes of records to remove and + // collect old records to restore (for replace ops). + var allRemoveLines []int + var allAddRecords []cpanel.MassEditAddRecord + needsBatch := false + for _, t := range zt { + action := accountinventory.ActionAdd + if t.action == "replace" { + action = accountinventory.ActionReplace + } + res := accountinventory.DNSApplyOpResult{ + PlanOp: accountinventory.PlanOp{Action: action, Type: t.opType, Name: t.opName, Records: t.records}, + } + var lines []int + for _, planned := range t.records { + line := findRecordLine(planned, records, zone) + if line >= 0 { + lines = append(lines, line) + } + } + if len(lines) == 0 && t.action == "add" { + res.Status = accountinventory.DNSOpSkipped + res.StatusReason = "records already absent, nothing to remove" + } else if len(lines) == 0 && t.action == "replace" { + res.Status = accountinventory.DNSOpRefused + res.StatusReason = "applied records not found in zone, cannot reverse replace" + } else if t.action == "replace" && len(t.oldRecords) == 0 { + res.Status = accountinventory.DNSOpRefused + res.StatusReason = "backup has no pre-apply records for this rrset, refusing to leave the zone without old or new values" + } else { + res.Status = accountinventory.DNSOpApplied // placeholder + allRemoveLines = append(allRemoveLines, lines...) + if t.action == "replace" { + needsBatch = true + for _, old := range t.oldRecords { + allAddRecords = append(allAddRecords, cpanel.MassEditAddRecord{ + DName: dnsCanonToRelative(old.Name, zone), + TTL: old.TTL, + RecordType: old.Type, + Data: old.Data, + }) + } + } + } + zr.Ops = append(zr.Ops, res) + } + + if len(allRemoveLines) > 0 { + var writeErr error + if needsBatch { + _, writeErr = cpanel.MassEditZoneBatch(ctx, client, zone, serial, allRemoveLines, allAddRecords) + } else { + _, writeErr = cpanel.MassEditZoneRemove(ctx, client, zone, serial, allRemoveLines) + } + if writeErr != nil { + status := accountinventory.DNSOpFailed + reason := writeErr.Error() + if cpanel.IsStaleSerialError(writeErr) { + status = accountinventory.DNSOpRefused + reason = "stale serial, zone was modified since fetch" + } + for i := range zr.Ops { + if zr.Ops[i].Status == accountinventory.DNSOpApplied { + zr.Ops[i].Status = status + zr.Ops[i].StatusReason = reason + } + } + } else { + freshRecords, _, verifyErr := cpanel.FetchDNSZoneRaw(ctx, client, zone) + if verifyErr != nil { + for i := range zr.Ops { + if zr.Ops[i].Status == accountinventory.DNSOpApplied { + zr.Ops[i].Status = accountinventory.DNSOpFailed + zr.Ops[i].StatusReason = "rollback verify-after re-fetch failed: " + verifyErr.Error() + } + } + } else { + for i, t := range zt { + if zr.Ops[i].Status != accountinventory.DNSOpApplied { + continue + } + // New records (written by apply) must be gone. + stillPresent := false + for _, rec := range t.records { + if dnsRecordPresent(rec, freshRecords, zone) { + stillPresent = true + break + } + } + if stillPresent { + zr.Ops[i].Status = accountinventory.DNSOpFailed + zr.Ops[i].StatusReason = "remove reported success but records are still present" + continue + } + // For replace: old records must be restored. + if t.action == "replace" && len(t.oldRecords) > 0 { + allRestored := true + for _, old := range t.oldRecords { + if !dnsRecordPresent(old, freshRecords, zone) { + allRestored = false + break + } + } + if !allRestored { + zr.Ops[i].Status = accountinventory.DNSOpFailed + zr.Ops[i].StatusReason = "old records not observable after restore" + } + } + } + } + } + } + + report.Zones = append(report.Zones, zr) + } + + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Summary = accountinventory.SummarizeDNSResults(report.Zones) + return finishDNSReport(report, outJSON, outMD) +} + +// dnsBackupRecordsForOp extracts the pre-apply records for a specific +// type+name from the backup zone. Used by replace rollback to know what +// values to restore. +func dnsBackupRecordsForOp(backup accountinventory.DNSApplyBackup, zone, opType, opName string) []accountinventory.PlanRecord { + canonName := strings.ToLower(opName) + for _, bz := range backup.Zones { + if bz.Zone != zone { + continue + } + var out []accountinventory.PlanRecord + for _, r := range bz.Records { + if !strings.EqualFold(r.Type, opType) { + continue + } + if dnsCanonLiveName(r.Name, zone) != canonName { + continue + } + out = append(out, backupEntryToPlanRecord(r, zone)) + } + return out + } + return nil +} + +// backupEntryToPlanRecord converts a backup record to PlanRecord for +// use in mass_edit_zone restore. +func backupEntryToPlanRecord(entry cpanel.DNSRecord, zone string) accountinventory.PlanRecord { + rec := accountinventory.PlanRecord{ + Name: dnsCanonLiveName(entry.Name, zone), + Type: entry.Type, + TTL: entry.TTL, + } + switch entry.Type { + case "A", "AAAA": + rec.Data = []string{entry.Address} + case "CNAME": + rec.Data = []string{dnsCanonLiveName(entry.Target, zone)} + case "MX": + rec.Data = []string{fmt.Sprintf("%d", entry.Priority), dnsCanonLiveName(entry.Exchange, zone)} + case "TXT": + rec.Data = []string{entry.TxtData} + } + return rec +} + +// findRecordLine finds the line index of a planned record in the live +// zone. Returns -1 if not found. +func findRecordLine(planned accountinventory.PlanRecord, live []cpanel.DNSRecord, zone string) int { + canonName := strings.ToLower(planned.Name) + for _, f := range live { + if !strings.EqualFold(f.Type, planned.Type) { + continue + } + if dnsCanonLiveName(f.Name, zone) != canonName { + continue + } + if dnsDataMatch(planned, f) { + return f.Line + } + } + return -1 +} diff --git a/cmd/cpanel-self-migration/dns_apply_cmd_test.go b/cmd/cpanel-self-migration/dns_apply_cmd_test.go new file mode 100644 index 00000000..cd0d5a09 --- /dev/null +++ b/cmd/cpanel-self-migration/dns_apply_cmd_test.go @@ -0,0 +1,1081 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "github.com/tis24dev/cPanel_self-migration/internal/sshtest" +) + +// --- stateful uapi stub for DNS ----------------------------------------------- + +// dnsStubScript is a STATEFUL `uapi` stub for DNS ops: zone records +// live in a flat JSON state file per zone under $CPSM_DNS_STATE. The +// stub handles DNS parse_zone and DNS mass_edit_zone (add and remove). +// bash 3.2 compatible (macOS). +const dnsStubScript = `#!/bin/bash +shift # drop --output=json +mod="$1"; fn="$2"; shift 2 +zone=""; serial="" +for kv in "$@"; do + case "$kv" in + zone=*) zone="${kv#zone=}";; + serial=*) serial="${kv#serial=}";; + esac +done +S="$CPSM_DNS_STATE" +ZFILE="$S/${zone}.json" +case "$mod $fn" in + "DNS parse_zone") + if [ -f "$ZFILE" ]; then + cat "$ZFILE" + else + echo '{"result":{"status":1,"data":[]}}' + fi + ;; + "DNS mass_edit_zone") + if [ ! -f "$ZFILE" ]; then + echo '{"result":{"status":0,"errors":["zone not found"]}}' + exit 0 + fi + # Read current serial from state (env var to avoid quoting issues) + export _STUB_ZFILE="$ZFILE" + cur_serial=$(python3 -c ' +import json,base64,os +with open(os.environ["_STUB_ZFILE"]) as f: data=json.load(f) +for r in data["result"]["data"]: + if r.get("record_type")=="SOA" and len(r.get("data_b64",[]))>=3: + print(base64.b64decode(r["data_b64"][2]).decode().strip()) + break +' 2>/dev/null) + if [ -n "$serial" ] && [ -n "$cur_serial" ] && [ "$serial" != "$cur_serial" ]; then + echo "{\"result\":{\"status\":0,\"errors\":[\"The serial number $serial does not match the DNS zone serial $cur_serial\"]}}" + exit 0 + fi + # Collect add-N and remove-N args + adds="" + removes="" + for kv in "$@"; do + case "$kv" in + add-*=*) adds="$adds ${kv#add-*=}";; + remove-*=*) removes="$removes ${kv#remove-*=}";; + esac + done + # Process via python3 (manipulate JSON state). + # Args are passed via env var to avoid triple-quote breakage from + # embedded " characters in JSON values (bash 5.2 Linux). + new_serial=$((cur_serial + 1)) + _STUB_ARGS="" + for _a in "$@"; do _STUB_ARGS="${_STUB_ARGS}"$'\t'"${_a}"; done + export _STUB_ARGS _STUB_ZFILE="$ZFILE" _STUB_NEW_SERIAL="$new_serial" + python3 -c ' +import json, sys, base64, os +from urllib.parse import unquote_plus +zfile = os.environ["_STUB_ZFILE"] +with open(zfile) as f: + state = json.load(f) +records = state["result"]["data"] +remove_lines = set() +for kv in os.environ["_STUB_ARGS"].split("\t"): + if kv.startswith("remove-") and "=" in kv: + idx = kv.split("=",1)[1] + try: remove_lines.add(int(idx)) + except: pass +if remove_lines: + records = [r for r in records if r.get("line_index") not in remove_lines] +max_line = max((r.get("line_index",0) for r in records), default=0) +for kv in os.environ["_STUB_ARGS"].split("\t"): + if kv.startswith("add-") and "=" in kv: + # Emulate cpsrvd: it form-url-decodes each uapi arg VALUE ("+"->space, + # "%XX"->byte). The tool percent-encodes values (encodeUAPIArgValue) so + # they round-trip; decoding here mirrors the real server. This is also + # the regression guard: drop the tool-side encoding and a "+" in the + # payload arrives here as a space -> verify-after would fail. + rec_json = unquote_plus(kv.split("=",1)[1]) + try: + rec = json.loads(rec_json) + max_line += 1 + data_b64 = [base64.b64encode(d.encode()).decode() for d in rec.get("data",[])] + records.append({ + "type": "record", + "record_type": rec["record_type"], + "dname_b64": base64.b64encode(rec["dname"].encode()).decode(), + "data_b64": data_b64, + "ttl": rec.get("ttl", 300), + "line_index": max_line, + }) + except Exception as e: + print(f"stub add parse error: {e}", file=sys.stderr) +new_ser = int(os.environ["_STUB_NEW_SERIAL"]) +for r in records: + if r.get("record_type") == "SOA" and len(r.get("data_b64",[])) >= 3: + r["data_b64"][2] = base64.b64encode(str(new_ser).encode()).decode() +state["result"]["data"] = records +with open(zfile, "w") as f: + json.dump(state, f) +' 2>/dev/null + echo "{\"result\":{\"status\":1,\"data\":{\"new_serial\":\"$new_serial\"}}}" + ;; + *) echo '{"result":{"status":0,"errors":["stub: unknown uapi call"]}}';; +esac +` + +// setupDNSApplyServer starts the in-process SSH server with the +// stateful DNS uapi stub and writes a host.yaml pointing at it. +func setupDNSApplyServer(t *testing.T) (cfgPath, stateDir string) { + t.Helper() + tmp := t.TempDir() + stubDir := filepath.Join(tmp, "bin") + stateDir = filepath.Join(tmp, "state") + for _, d := range []string{stubDir, stateDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(stubDir, "uapi"), []byte(dnsStubScript), 0o755); err != nil { // #nosec G306 -- stub must be executable + t.Fatal(err) + } + t.Setenv("CPSM_DNS_STATE", stateDir) + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HOME", tmp) + + addr := sshtest.NewExecServer(t, tmp) + host, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatal(err) + } + cfgPath = filepath.Join(tmp, "host.yaml") + yaml := fmt.Sprintf(`src: + ip: %[1]s + port: %[2]s + ssh_user: u + ssh_pass: p + timeout: 10s +dest: + ip: %[1]s + port: %[2]s + ssh_user: u + ssh_pass: p + timeout: 10s +`, host, port) + if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + return cfgPath, stateDir +} + +// writeDNSZoneState writes a UAPI parse_zone response for a zone into +// the stub state directory. +func writeDNSZoneState(t *testing.T, stateDir, zone string, records []cpanel.DNSRecord, serial int) { + t.Helper() + b64 := func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + type rawRec struct { + Type string `json:"type"` + RecordType string `json:"record_type"` + DNameB64 string `json:"dname_b64"` + DataB64 []string `json:"data_b64"` + TTL int `json:"ttl"` + LineIndex int `json:"line_index"` + } + var raws []rawRec + // Always include a SOA record for serial tracking. + raws = append(raws, rawRec{ + Type: "record", RecordType: "SOA", DNameB64: b64(zone + "."), + DataB64: []string{b64("ns1." + zone + "."), b64("admin." + zone + "."), b64(fmt.Sprintf("%d", serial))}, + TTL: 86400, LineIndex: 1, + }) + for i, r := range records { + rec := rawRec{ + Type: "record", RecordType: r.Type, DNameB64: b64(r.Name), + TTL: r.TTL, LineIndex: i + 2, + } + switch r.Type { + case "A", "AAAA": + rec.DataB64 = []string{b64(r.Address)} + case "CNAME": + rec.DataB64 = []string{b64(r.Target)} + case "MX": + rec.DataB64 = []string{b64(fmt.Sprintf("%d", r.Priority)), b64(r.Exchange)} + case "TXT": + rec.DataB64 = []string{b64(r.TxtData)} + default: + rec.DataB64 = []string{b64(r.Value)} + } + raws = append(raws, rec) + } + payload := map[string]any{"result": map[string]any{"status": 1, "data": raws}} + b, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(stateDir, zone+".json") + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } +} + +// writeDNSTestPlan writes a minimal dns_import_plan.json with the +// given zones. +func writeDNSTestPlan(t *testing.T, dir string, zones []accountinventory.PlanZone) string { + t.Helper() + plan := accountinventory.DNSPlan{ + Mode: "dns-import-plan", + FormatVersion: 1, + IPMap: map[string]string{}, + Zones: zones, + } + for _, z := range zones { + for _, op := range z.Ops { + switch op.Action { + case accountinventory.ActionAdd: + plan.Summary.Add++ + case accountinventory.ActionReplace: + plan.Summary.Replace++ + case accountinventory.ActionManual: + plan.Summary.Manual++ + case accountinventory.ActionSkip: + plan.Summary.Skip++ + } + } + } + planPath := filepath.Join(dir, "dns_import_plan.json") + b, err := json.MarshalIndent(plan, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(planPath, b, 0o600); err != nil { + t.Fatal(err) + } + return planPath +} + +func readDNSApplyReport(t *testing.T, path string) accountinventory.DNSApplyReport { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read report: %v", err) + } + var r accountinventory.DNSApplyReport + if err := json.Unmarshal(b, &r); err != nil { + t.Fatalf("parse report: %v", err) + } + return r +} + +// --- flag/input errors ------------------------------------------------------- + +func TestDNSApplyCmdFlagAndInputErrors(t *testing.T) { + dir := t.TempDir() + if code := runDNSApplyCmd([]string{"--bogus"}); code != 2 { + t.Errorf("unknown flag: code = %d, want 2", code) + } + if code := runDNSApplyCmd([]string{}); code != 1 { + t.Errorf("missing --plan: code = %d, want 1", code) + } + if code := runDNSApplyCmd([]string{"--plan", "x.json", "--rollback", "y.json"}); code != 2 { + t.Errorf("--plan + --rollback: code = %d, want 2", code) + } + if code := runDNSApplyCmd([]string{"--plan", "x.json", "--report", "y.json"}); code != 2 { + t.Errorf("--report without --rollback: code = %d, want 2", code) + } + badMode := filepath.Join(dir, "badmode.json") + if err := os.WriteFile(badMode, []byte(`{"mode":"email-apply-plan"}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runDNSApplyCmd([]string{"--plan", badMode}); code != 1 { + t.Errorf("wrong mode: code = %d, want 1", code) + } + badVer := filepath.Join(dir, "badver.json") + if err := os.WriteFile(badVer, []byte(`{"mode":"dns-import-plan","format_version":9}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runDNSApplyCmd([]string{"--plan", badVer}); code != 1 { + t.Errorf("unknown format_version: code = %d, want 1", code) + } +} + +// --- dry-run ----------------------------------------------------------------- + +func TestDNSApplyCmdDryRunIsOffline(t *testing.T) { + dir := t.TempDir() + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionAdd, + Type: "A", + Name: "www.example.com.", + Records: []accountinventory.PlanRecord{ + {Name: "www.example.com.", Type: "A", TTL: 300, Data: []string{"1.2.3.4"}}, + }, + }, + }, + }, + }) + + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + empty := t.TempDir() + if err := os.Chdir(empty); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(cwd) }() + + if code := runDNSApplyCmd([]string{"--plan", planPath}); code != 0 { + t.Fatalf("dry-run: code = %d, want 0 (offline, no config needed)", code) + } + entries, err := os.ReadDir(empty) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Errorf("dry-run wrote artifacts: %v", entries) + } +} + +// --- apply and verify -------------------------------------------------------- + +func TestDNSApplyCmdApplyAndVerify(t *testing.T) { + cfgPath, stateDir := setupDNSApplyServer(t) + dir := t.TempDir() + + // Set up the destination zone with just a SOA (empty zone). + writeDNSZoneState(t, stateDir, "example.com", nil, 2024010100) + + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionAdd, + Type: "A", + Name: "www.example.com.", + Records: []accountinventory.PlanRecord{ + {Name: "www.example.com.", Type: "A", TTL: 300, Data: []string{"1.2.3.4"}}, + }, + }, + { + Action: accountinventory.ActionSkip, + Type: "A", + Name: "example.com.", + Reason: "identical", + }, + }, + }, + }) + + outJSON := filepath.Join(dir, "dns_apply_report.json") + backupPath := filepath.Join(dir, "dns_backup_test.json") + + code := runDNSApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }) + if code != 0 { + if b, err := os.ReadFile(outJSON); err == nil { + t.Logf("report:\n%s", string(b)) + } + t.Fatalf("apply: code = %d, want 0", code) + } + + rep := readDNSApplyReport(t, outJSON) + + if rep.Summary.Applied != 1 { + t.Errorf("summary.Applied = %d, want 1", rep.Summary.Applied) + } + if rep.Summary.Failed != 0 { + t.Errorf("summary.Failed = %d, want 0", rep.Summary.Failed) + } + if rep.Summary.Skipped != 1 { + t.Errorf("summary.Skipped = %d, want 1", rep.Summary.Skipped) + } + + if len(rep.Zones) != 1 { + t.Fatalf("zones = %d, want 1", len(rep.Zones)) + } + zr := rep.Zones[0] + if len(zr.Ops) != 2 { + t.Fatalf("ops = %d, want 2", len(zr.Ops)) + } + for _, op := range zr.Ops { + switch { + case op.Type == "A" && strings.Contains(op.Name, "www"): + if op.Status != accountinventory.DNSOpApplied { + t.Errorf("A www: status = %q, want applied", op.Status) + } + case op.Type == "A": + if op.Status != accountinventory.DNSOpSkipped { + t.Errorf("A apex skip: status = %q, want skipped", op.Status) + } + } + } + + // Bidirectional pairing: report <-> backup. + if rep.BackupFile != backupPath || rep.BackupSHA256 == "" { + t.Errorf("report backup pairing = %q/%q", rep.BackupFile, rep.BackupSHA256) + } + + // Read the zone state to confirm the record was actually added. + zoneState, err := os.ReadFile(filepath.Join(stateDir, "example.com.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(zoneState), base64.StdEncoding.EncodeToString([]byte("1.2.3.4"))) { + t.Error("A record 1.2.3.4 not found in zone state after apply") + } +} + +// TestDNSApplyCmdEmptyRecordsOpStaysFailed guards the verify-after loop +// against resurrecting a malformed add op. An add op with zero records is +// flagged Failed before the write; a sibling valid op's successful write +// in the SAME zone must not promote it back to applied +// (dnsVerifyAddOpPresent is vacuously true for an empty Records slice). +func TestDNSApplyCmdEmptyRecordsOpStaysFailed(t *testing.T) { + cfgPath, stateDir := setupDNSApplyServer(t) + dir := t.TempDir() + writeDNSZoneState(t, stateDir, "example.com", nil, 2024010100) + + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { // valid sibling: gives the zone a non-empty write, succeeds + Action: accountinventory.ActionAdd, + Type: "A", + Name: "www.example.com.", + Records: []accountinventory.PlanRecord{ + {Name: "www.example.com.", Type: "A", TTL: 300, Data: []string{"1.2.3.4"}}, + }, + }, + { // malformed: add op carrying zero records + Action: accountinventory.ActionAdd, + Type: "A", + Name: "empty.example.com.", + Records: nil, + }, + }, + }, + }) + + outJSON := filepath.Join(dir, "dns_apply_report.json") + // A Failed op makes the run exit non-zero; the STATUS is the point. + _ = runDNSApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", filepath.Join(dir, "dns_backup.json"), "--output-json", outJSON, + }) + + rep := readDNSApplyReport(t, outJSON) + if len(rep.Zones) != 1 { + t.Fatalf("zones = %d, want 1", len(rep.Zones)) + } + var emptyStatus, wwwStatus string + for _, op := range rep.Zones[0].Ops { + switch { + case strings.Contains(op.Name, "empty"): + emptyStatus = op.Status + case strings.Contains(op.Name, "www"): + wwwStatus = op.Status + } + } + if emptyStatus != accountinventory.DNSOpFailed { + t.Errorf("empty-records op status = %q, want failed (must not be resurrected to applied)", emptyStatus) + } + if wwwStatus != accountinventory.DNSOpApplied { + t.Errorf("valid sibling op status = %q, want applied", wwwStatus) + } + if rep.Summary.Failed < 1 { + t.Errorf("summary.Failed = %d, want >= 1", rep.Summary.Failed) + } +} + +// --- rollback ---------------------------------------------------------------- + +func TestDNSApplyCmdRollback(t *testing.T) { + cfgPath, stateDir := setupDNSApplyServer(t) + dir := t.TempDir() + + // Set up zone. + writeDNSZoneState(t, stateDir, "example.com", nil, 2024010100) + + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionAdd, + Type: "A", + Name: "test.example.com.", + Records: []accountinventory.PlanRecord{ + {Name: "test.example.com.", Type: "A", TTL: 300, Data: []string{"5.6.7.8"}}, + }, + }, + }, + }, + }) + + outJSON := filepath.Join(dir, "dns_apply_report.json") + backupPath := filepath.Join(dir, "dns_backup_test.json") + + // Apply first. + code := runDNSApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }) + if code != 0 { + t.Fatalf("apply: code = %d, want 0", code) + } + + rep := readDNSApplyReport(t, outJSON) + if rep.Summary.Applied != 1 { + t.Fatalf("apply summary = %+v, want 1 applied", rep.Summary) + } + + // Verify the record is present. + zoneState, err := os.ReadFile(filepath.Join(stateDir, "example.com.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(zoneState), base64.StdEncoding.EncodeToString([]byte("5.6.7.8"))) { + t.Fatal("A record 5.6.7.8 not found in zone state after apply") + } + + // Rollback dry-run: offline, writes nothing. + rbJSON := filepath.Join(dir, "dns_rollback_report.json") + if code := runDNSApplyCmd([]string{"--rollback", backupPath, "--output-json", rbJSON}); code != 0 { + t.Fatalf("rollback dry-run: code = %d, want 0", code) + } + if _, err := os.Stat(rbJSON); !os.IsNotExist(err) { + t.Error("rollback dry-run must not write a report") + } + + // Real rollback. + if code := runDNSApplyCmd([]string{ + "--rollback", backupPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", rbJSON, + }); code != 0 { + t.Fatalf("rollback: code = %d, want 0", code) + } + + rb := readDNSApplyReport(t, rbJSON) + if rb.RunMode != "rollback" { + t.Errorf("rollback report run_mode = %q, want rollback", rb.RunMode) + } + if rb.Summary.Applied != 1 { + t.Fatalf("rollback summary = %+v, want 1 applied (removed)", rb.Summary) + } + + // Verify the record was removed. + zoneState, err = os.ReadFile(filepath.Join(stateDir, "example.com.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(zoneState), base64.StdEncoding.EncodeToString([]byte("5.6.7.8"))) { + t.Error("A record 5.6.7.8 still present after rollback") + } +} + +// --- replace v2 ------------------------------------------------------------- + +func TestDNSApplyCmdReplaceApply(t *testing.T) { + cfgPath, stateDir := setupDNSApplyServer(t) + dir := t.TempDir() + + // Zone with an A record pointing to the OLD address. + writeDNSZoneState(t, stateDir, "example.com", []cpanel.DNSRecord{ + {Type: "A", Name: "example.com.", TTL: 300, Address: "10.0.0.1"}, + }, 2024010100) + + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionReplace, + Type: "A", + Name: "example.com.", + DestinationValues: []string{"10.0.0.1"}, + Records: []accountinventory.PlanRecord{ + {Name: "example.com.", Type: "A", TTL: 300, Data: []string{"10.0.0.2"}}, + }, + }, + }, + }, + }) + + outJSON := filepath.Join(dir, "dns_apply_report.json") + backupPath := filepath.Join(dir, "dns_backup_test.json") + + code := runDNSApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }) + if code != 0 { + if b, err := os.ReadFile(outJSON); err == nil { + t.Logf("report:\n%s", string(b)) + } + t.Fatalf("replace apply: code = %d, want 0", code) + } + + rep := readDNSApplyReport(t, outJSON) + if rep.Summary.Applied != 1 { + t.Errorf("summary.Applied = %d, want 1", rep.Summary.Applied) + } + if rep.Summary.Failed != 0 { + t.Errorf("summary.Failed = %d, want 0", rep.Summary.Failed) + } + + // Verify the zone state: old value gone, new value present. + zoneState, err := os.ReadFile(filepath.Join(stateDir, "example.com.json")) + if err != nil { + t.Fatal(err) + } + newB64 := base64.StdEncoding.EncodeToString([]byte("10.0.0.2")) + oldB64 := base64.StdEncoding.EncodeToString([]byte("10.0.0.1")) + if !strings.Contains(string(zoneState), newB64) { + t.Error("new address 10.0.0.2 not found in zone state after replace") + } + if strings.Contains(string(zoneState), oldB64) { + t.Error("old address 10.0.0.1 still present in zone state after replace") + } +} + +func TestDNSApplyCmdReplaceAlreadyPresent(t *testing.T) { + cfgPath, stateDir := setupDNSApplyServer(t) + dir := t.TempDir() + + // Zone already has the desired value, no write needed. + writeDNSZoneState(t, stateDir, "example.com", []cpanel.DNSRecord{ + {Type: "A", Name: "example.com.", TTL: 300, Address: "10.0.0.2"}, + }, 2024010100) + + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionReplace, + Type: "A", + Name: "example.com.", + DestinationValues: []string{"10.0.0.1"}, + Records: []accountinventory.PlanRecord{ + {Name: "example.com.", Type: "A", TTL: 300, Data: []string{"10.0.0.2"}}, + }, + }, + }, + }, + }) + + outJSON := filepath.Join(dir, "dns_apply_report.json") + code := runDNSApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", outJSON, + }) + if code != 0 { + t.Fatalf("already_present: code = %d, want 0", code) + } + + rep := readDNSApplyReport(t, outJSON) + if rep.Summary.Skipped != 1 { + t.Errorf("summary.Skipped = %d, want 1 (already_present)", rep.Summary.Skipped) + } + if rep.Summary.Applied != 0 { + t.Errorf("summary.Applied = %d, want 0", rep.Summary.Applied) + } +} + +func TestDNSApplyCmdReplaceDriftRefused(t *testing.T) { + cfgPath, stateDir := setupDNSApplyServer(t) + dir := t.TempDir() + + // Zone has a THIRD value (neither plan-time dest nor desired). + writeDNSZoneState(t, stateDir, "example.com", []cpanel.DNSRecord{ + {Type: "A", Name: "example.com.", TTL: 300, Address: "99.99.99.99"}, + }, 2024010100) + + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionReplace, + Type: "A", + Name: "example.com.", + DestinationValues: []string{"10.0.0.1"}, + Records: []accountinventory.PlanRecord{ + {Name: "example.com.", Type: "A", TTL: 300, Data: []string{"10.0.0.2"}}, + }, + }, + }, + }, + }) + + outJSON := filepath.Join(dir, "dns_apply_report.json") + code := runDNSApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", outJSON, + }) + if code != exitDriftGate { + t.Fatalf("drift refused: code = %d, want %d", code, exitDriftGate) + } + + rep := readDNSApplyReport(t, outJSON) + if rep.Summary.Refused != 1 { + t.Errorf("summary.Refused = %d, want 1", rep.Summary.Refused) + } +} + +func TestDNSApplyCmdReplaceMixedWithAdd(t *testing.T) { + cfgPath, stateDir := setupDNSApplyServer(t) + dir := t.TempDir() + + // Zone has an A record to replace + is missing a TXT to add. + writeDNSZoneState(t, stateDir, "example.com", []cpanel.DNSRecord{ + {Type: "A", Name: "example.com.", TTL: 300, Address: "10.0.0.1"}, + }, 2024010100) + + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionReplace, + Type: "A", + Name: "example.com.", + DestinationValues: []string{"10.0.0.1"}, + Records: []accountinventory.PlanRecord{ + {Name: "example.com.", Type: "A", TTL: 300, Data: []string{"10.0.0.2"}}, + }, + }, + { + Action: accountinventory.ActionAdd, + Type: "TXT", + Name: "example.com.", + Records: []accountinventory.PlanRecord{ + {Name: "example.com.", Type: "TXT", TTL: 300, Data: []string{"v=spf1 +a ~all"}}, + }, + }, + }, + }, + }) + + outJSON := filepath.Join(dir, "dns_apply_report.json") + code := runDNSApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", outJSON, + }) + if code != 0 { + if b, err := os.ReadFile(outJSON); err == nil { + t.Logf("report:\n%s", string(b)) + } + t.Fatalf("mixed replace+add: code = %d, want 0", code) + } + + rep := readDNSApplyReport(t, outJSON) + if rep.Summary.Applied != 2 { + t.Errorf("summary.Applied = %d, want 2 (1 replace + 1 add)", rep.Summary.Applied) + } + + zoneState, err := os.ReadFile(filepath.Join(stateDir, "example.com.json")) + if err != nil { + t.Fatal(err) + } + zs := string(zoneState) + if !strings.Contains(zs, base64.StdEncoding.EncodeToString([]byte("10.0.0.2"))) { + t.Error("replaced A record 10.0.0.2 not found") + } + if !strings.Contains(zs, base64.StdEncoding.EncodeToString([]byte("v=spf1 +a ~all"))) { + t.Error("added TXT record not found") + } +} + +func TestDNSApplyCmdReplaceRollback(t *testing.T) { + cfgPath, stateDir := setupDNSApplyServer(t) + dir := t.TempDir() + + // Zone with old A value. + writeDNSZoneState(t, stateDir, "example.com", []cpanel.DNSRecord{ + {Type: "A", Name: "example.com.", TTL: 300, Address: "10.0.0.1"}, + }, 2024010100) + + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionReplace, + Type: "A", + Name: "example.com.", + DestinationValues: []string{"10.0.0.1"}, + Records: []accountinventory.PlanRecord{ + {Name: "example.com.", Type: "A", TTL: 300, Data: []string{"10.0.0.2"}}, + }, + }, + }, + }, + }) + + outJSON := filepath.Join(dir, "dns_apply_report.json") + backupPath := filepath.Join(dir, "dns_backup_test.json") + + // Apply. + code := runDNSApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }) + if code != 0 { + t.Fatalf("apply: code = %d, want 0", code) + } + + // Verify new value is live. + zs, _ := os.ReadFile(filepath.Join(stateDir, "example.com.json")) + if !strings.Contains(string(zs), base64.StdEncoding.EncodeToString([]byte("10.0.0.2"))) { + t.Fatal("new A 10.0.0.2 not found after apply") + } + + // Rollback. + rbJSON := filepath.Join(dir, "dns_rollback_report.json") + code = runDNSApplyCmd([]string{ + "--rollback", backupPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", rbJSON, + }) + if code != 0 { + if b, err := os.ReadFile(rbJSON); err == nil { + t.Logf("rollback report:\n%s", string(b)) + } + t.Fatalf("rollback: code = %d, want 0", code) + } + + rb := readDNSApplyReport(t, rbJSON) + if rb.Summary.Applied != 1 { + t.Errorf("rollback summary.Applied = %d, want 1", rb.Summary.Applied) + } + + // Verify old value is restored. + zs, _ = os.ReadFile(filepath.Join(stateDir, "example.com.json")) + zsStr := string(zs) + if !strings.Contains(zsStr, base64.StdEncoding.EncodeToString([]byte("10.0.0.1"))) { + t.Error("old A 10.0.0.1 not found after rollback") + } + if strings.Contains(zsStr, base64.StdEncoding.EncodeToString([]byte("10.0.0.2"))) { + t.Error("new A 10.0.0.2 still present after rollback") + } +} + +func TestDNSApplyCmdReplaceGrowthDriftRefused(t *testing.T) { + cfgPath, stateDir := setupDNSApplyServer(t) + dir := t.TempDir() + + // Zone has TWO A records but the plan only expected ONE (growth drift). + writeDNSZoneState(t, stateDir, "example.com", []cpanel.DNSRecord{ + {Type: "A", Name: "example.com.", TTL: 300, Address: "10.0.0.1"}, + {Type: "A", Name: "example.com.", TTL: 300, Address: "10.0.0.9"}, + }, 2024010100) + + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionReplace, + Type: "A", + Name: "example.com.", + DestinationValues: []string{"10.0.0.1"}, + Records: []accountinventory.PlanRecord{ + {Name: "example.com.", Type: "A", TTL: 300, Data: []string{"10.0.0.2"}}, + }, + }, + }, + }, + }) + + outJSON := filepath.Join(dir, "dns_apply_report.json") + code := runDNSApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", outJSON, + }) + if code != exitDriftGate { + t.Fatalf("growth drift: code = %d, want %d", code, exitDriftGate) + } + + rep := readDNSApplyReport(t, outJSON) + if rep.Summary.Refused != 1 { + t.Errorf("summary.Refused = %d, want 1", rep.Summary.Refused) + } +} + +func TestDNSApplyCmdRollbackRefusesEmptyOldRecords(t *testing.T) { + cfgPath, stateDir := setupDNSApplyServer(t) + dir := t.TempDir() + + // Apply a replace first. + writeDNSZoneState(t, stateDir, "example.com", []cpanel.DNSRecord{ + {Type: "A", Name: "example.com.", TTL: 300, Address: "10.0.0.1"}, + }, 2024010100) + + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionReplace, + Type: "A", + Name: "example.com.", + DestinationValues: []string{"10.0.0.1"}, + Records: []accountinventory.PlanRecord{ + {Name: "example.com.", Type: "A", TTL: 300, Data: []string{"10.0.0.2"}}, + }, + }, + }, + }, + }) + + outJSON := filepath.Join(dir, "dns_apply_report.json") + backupPath := filepath.Join(dir, "dns_backup_test.json") + code := runDNSApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }) + if code != 0 { + t.Fatalf("apply: code = %d, want 0", code) + } + + // Corrupt the backup: remove the A record from the zone records. + backupRaw, _ := os.ReadFile(backupPath) + var backup accountinventory.DNSApplyBackup + if err := json.Unmarshal(backupRaw, &backup); err != nil { + t.Fatal(err) + } + for i := range backup.Zones { + backup.Zones[i].Records = nil // empty records + } + corruptedBackup, _ := json.Marshal(backup) + corruptedPath := filepath.Join(dir, "dns_backup_corrupted.json") + if err := os.WriteFile(corruptedPath, corruptedBackup, 0o600); err != nil { + t.Fatal(err) + } + + // Rollback with corrupted backup should refuse (not silently delete). + rbJSON := filepath.Join(dir, "dns_rollback_report.json") + code = runDNSApplyCmd([]string{ + "--rollback", corruptedPath, "--report", outJSON, + "--config", cfgPath, "--yes-apply-writes", + "--output-json", rbJSON, + }) + if code != exitDriftGate { + t.Fatalf("rollback with empty oldRecords: code = %d, want %d (refused)", code, exitDriftGate) + } + + rb := readDNSApplyReport(t, rbJSON) + if rb.Summary.Refused != 1 { + t.Errorf("rollback summary.Refused = %d, want 1", rb.Summary.Refused) + } + + // Verify the zone was NOT modified (record still 10.0.0.2). + zs, _ := os.ReadFile(filepath.Join(stateDir, "example.com.json")) + if !strings.Contains(string(zs), base64.StdEncoding.EncodeToString([]byte("10.0.0.2"))) { + t.Error("record should still be 10.0.0.2 after refused rollback") + } +} + +func TestDNSApplyCmdDryRunShowsReplace(t *testing.T) { + dir := t.TempDir() + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "example.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionReplace, + Type: "A", + Name: "example.com.", + DestinationValues: []string{"10.0.0.1"}, + Records: []accountinventory.PlanRecord{ + {Name: "example.com.", Type: "A", TTL: 300, Data: []string{"10.0.0.2"}}, + }, + }, + }, + }, + }) + + cwd, _ := os.Getwd() + empty := t.TempDir() + _ = os.Chdir(empty) + defer func() { _ = os.Chdir(cwd) }() + + // Dry-run should exit 0 and write nothing. + code := runDNSApplyCmd([]string{"--plan", planPath}) + if code != 0 { + t.Fatalf("dry-run with replace: code = %d, want 0", code) + } +} + +// --- serial extraction failure path ------------------------------------------ + +func TestDNSApplyCmdNoSOAExitsError(t *testing.T) { + cfgPath, stateDir := setupDNSApplyServer(t) + dir := t.TempDir() + + noSOAZone := filepath.Join(stateDir, "nosoa.com.json") + if err := os.WriteFile(noSOAZone, []byte(`{"result":{"status":1,"data":[{"type":"record","record_type":"A","dname_b64":"`+ + base64.StdEncoding.EncodeToString([]byte("nosoa.com."))+ + `","data_b64":["`+base64.StdEncoding.EncodeToString([]byte("1.2.3.4"))+ + `"],"ttl":300,"line_index":1}]}}`), 0o600); err != nil { + t.Fatal(err) + } + planPath := writeDNSTestPlan(t, dir, []accountinventory.PlanZone{ + { + Zone: "nosoa.com", + Ops: []accountinventory.PlanOp{ + { + Action: accountinventory.ActionAdd, + Type: "TXT", + Name: "test.nosoa.com.", + Records: []accountinventory.PlanRecord{{Name: "test.nosoa.com.", Type: "TXT", TTL: 300, Data: []string{"test"}}}, + }, + }, + }, + }) + + code := runDNSApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", filepath.Join(dir, "r.json"), + }) + if code != 1 { + t.Fatalf("no-SOA zone: exit code = %d, want 1 (serial extraction error)", code) + } +} + +// TestDNSCanonToRelativeApexUsesFQDN pins the apex dname form for +// mass_edit_zone. cPanel's mass_edit_zone REJECTS "@" as the apex shorthand: +// it fails the WHOLE atomic batch with status=0 "The request failed (Error ID +// ...)". This was the real N1 dogfooding failure (the apex SPF replace). The +// apex must be sent as the fully-qualified zone name (trailing dot); non-apex +// names stay relative. Reproduced byte-for-byte on a live host: add dname="@" +// -> status=0; add dname="example.net." -> status=1. +func TestDNSCanonToRelativeApexUsesFQDN(t *testing.T) { + cases := []struct{ canonical, zone, want string }{ + {"example.net.", "example.net", "example.net."}, // apex -> FQDN, NOT "@" + {"EXAMPLE.NET.", "example.net", "example.net."}, // case-insensitive apex + {"default._domainkey.example.net.", "example.net", "default._domainkey"}, // subdomain -> relative + {"_v2smoke.example.net.", "example.net", "_v2smoke"}, // subdomain -> relative + {"other.example.com.", "example.net", "other.example.com."}, // unrelated -> unchanged + } + for _, c := range cases { + if got := dnsCanonToRelative(c.canonical, c.zone); got != c.want { + t.Errorf("dnsCanonToRelative(%q,%q) = %q, want %q", c.canonical, c.zone, got, c.want) + } + } +} diff --git a/cmd/cpanel-self-migration/dns_verify_cmd.go b/cmd/cpanel-self-migration/dns_verify_cmd.go new file mode 100644 index 00000000..e1b86256 --- /dev/null +++ b/cmd/cpanel-self-migration/dns_verify_cmd.go @@ -0,0 +1,161 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/config" + "github.com/tis24dev/cPanel_self-migration/internal/sshx" +) + +// exitDriftGate is returned when --fail-on-drift is set and the verify +// verdict is not clean, or when the stale-plan gate refuses the plan +// (house convention: 3 = gated refusal, like --fail-on-blockers). +const exitDriftGate = 3 + +// runDNSVerifyCmd implements `cpanel-self-migration dns verify`: re-fetch +// the DESTINATION zones named by a dns_import_plan.json (read-only, UAPI -> +// API2 fallback, the collector's own fetch) and report, per planned op, +// whether the live zone matches the plan (PR 6C). The source host is never +// dialed: it may already be decommissioned when verify runs. +// +// Exit codes: 0 verify ran and reports were written (even with drift); +// 1 invalid input, config/SSH-dial failure or write failure; 2 flag +// parsing; 3 gated refusal (stale plan, or --fail-on-drift + not clean). +func runDNSVerifyCmd(args []string) int { + fs := flag.NewFlagSet("dns verify", flag.ContinueOnError) + planPath := fs.String("plan", "", "path to dns_import_plan.json (required)") + cfgFlag := fs.String("config", "", "path to host.yaml (default: configs/host.yaml or host.yaml)") + srcInv := fs.String("source", "", "optional source inventory JSON: refuse the plan unless its embedded source_sha256 matches this file") + destInv := fs.String("destination", "", "optional destination inventory JSON: refuse the plan unless its embedded destination_sha256 matches this file") + outJSON := fs.String("output-json", "dns_verify_report.json", "verify report JSON output path") + outMD := fs.String("output-md", "dns_verify_report.md", "verify report Markdown output path") + failOnDrift := fs.Bool("fail-on-drift", false, "exit 3 unless the verify verdict is clean (no pending, drift, unavailable or manual zones)") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration dns verify --plan dns_import_plan.json [--config host.yaml] [--source src.json] [--destination dest.json] [--fail-on-drift]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if *planPath == "" { + fmt.Fprintln(os.Stderr, "error: --plan is required") + fs.Usage() + return 1 + } + + plan, err := loadDNSPlanFile(*planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if plan.FormatVersion != 1 { + fmt.Fprintf(os.Stderr, "error: %s: unsupported plan format_version %d (this build understands 1)\n", *planPath, plan.FormatVersion) + return 1 + } + + // Stale-plan gate (before any SSH): when the operator points at the + // inventories, their hashes must match the ones the plan was built + // from. A plan with no embedded hash cannot be validated, fail-safe + // refusal rather than a silent pass. + for _, in := range []struct{ flagName, path, embedded string }{ + {"--source", *srcInv, plan.SourceSHA256}, + {"--destination", *destInv, plan.DestinationSHA256}, + } { + if in.path == "" { + continue + } + sum, err := fileSHA256(in.path) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if in.embedded == "" { + fmt.Fprintf(os.Stderr, "refused: the plan embeds no sha256 for %s, cannot prove it was built from %s (rebuild the plan)\n", in.flagName, in.path) + return exitDriftGate + } + if sum != in.embedded { + fmt.Fprintf(os.Stderr, "refused: stale plan, %s %s hashes to %s but the plan was built from %s\n", in.flagName, in.path, sum, in.embedded) + return exitDriftGate + } + } + + planSHA, err := fileSHA256(*planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + // Fetch the live destination zones, only when the plan has zones to + // verify. An all-manual plan needs no config and opens no SSH. + ctx := context.Background() + live := map[string]accountinventory.DNSZoneResult{} + if len(plan.Zones) > 0 { + path, alternates, err := resolveConfigPath(*cfgFlag) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + for _, alt := range alternates { + fmt.Fprintf(os.Stderr, "note: multiple host.yaml candidates, using %s (ignoring %s)\n", path, alt) + } + cfg, err := config.Load(path) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if !cfg.DestConfigured() { + fmt.Fprintln(os.Stderr, "error: dns verify needs the DESTINATION host configured in", path) + return 1 + } + client, err := sshx.DialDest(ctx, cfg, "") + if err != nil { + fmt.Fprintln(os.Stderr, "error: dial destination:", err) + return 1 + } + defer func() { _ = client.Close() }() + for _, pz := range plan.Zones { + zone := strings.ToLower(pz.Zone) + live[zone] = accountinventory.FetchDNSZone(ctx, client, zone) + } + } + + rep := accountinventory.VerifyDNSPlan(plan, live) + rep.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + rep.PlanFile = *planPath + rep.PlanSHA256 = planSHA + + if err := accountinventory.WriteDNSVerifyJSON(*outJSON, rep); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := accountinventory.WriteDNSVerifyMarkdown(*outMD, rep); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + verdict := "CLEAN" + if !rep.Clean { + verdict = "NOT CLEAN" + } + fmt.Printf("dns verify: %s, %d applied, %d unchanged, %d pending, %d drift, %d manual review, %d not checked, %d untracked; %d unavailable zone(s), %d manual zone(s)\n", + verdict, rep.Summary.Applied, rep.Summary.Unchanged, rep.Summary.Pending, rep.Summary.Drift, + rep.Summary.ManualReview, rep.Summary.NotChecked, rep.Summary.Untracked, + rep.Summary.UnavailableZones, rep.Summary.ManualZones) + fmt.Fprintf(os.Stderr, "wrote %s\n", *outJSON) + fmt.Fprintf(os.Stderr, "wrote %s\n", *outMD) + + if *failOnDrift && !rep.Clean { + fmt.Fprintln(os.Stderr, "verdict not clean and --fail-on-drift is set, exiting 3") + return exitDriftGate + } + return 0 +} diff --git a/cmd/cpanel-self-migration/dns_verify_cmd_test.go b/cmd/cpanel-self-migration/dns_verify_cmd_test.go new file mode 100644 index 00000000..89d00509 --- /dev/null +++ b/cmd/cpanel-self-migration/dns_verify_cmd_test.go @@ -0,0 +1,308 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "github.com/tis24dev/cPanel_self-migration/internal/sshtest" +) + +// --- fixtures ------------------------------------------------------------- + +func writeDNSVerifyInventory(t *testing.T, dir, name, side string, zones ...accountinventory.DNSZoneResult) string { + t.Helper() + inv := accountinventory.NewEmptyInventory("u", "h", side) + inv.DNS.Available = true + inv.DNS.Zones = zones + b, err := json.MarshalIndent(inv, "", " ") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func dnsVerifyZone(zone string, records ...cpanel.DNSRecord) accountinventory.DNSZoneResult { + return accountinventory.DNSZoneResult{Available: true, Zone: zone, Method: "uapi", Records: records} +} + +func dnsVerifyARec(name, addr string) cpanel.DNSRecord { + return cpanel.DNSRecord{Type: "A", Name: name, TTL: 300, Address: addr, Value: addr} +} + +func writeDNSVerifyPlanFile(t *testing.T, dir string, plan accountinventory.DNSPlan) string { + t.Helper() + path := filepath.Join(dir, "plan.json") + if err := accountinventory.WriteDNSPlanJSON(path, plan); err != nil { + t.Fatal(err) + } + return path +} + +// writeUAPIZoneFixture renders the raw UAPI DNS::parse_zone response the +// stub `uapi` serves: real-server shape (base64 dname/data segments). +func writeUAPIZoneFixture(t *testing.T, dir, name string, records ...cpanel.DNSRecord) string { + t.Helper() + b64 := func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + type rawRec struct { + Type string `json:"type"` + RecordType string `json:"record_type"` + DNameB64 string `json:"dname_b64"` + DataB64 []string `json:"data_b64"` + TTL int `json:"ttl"` + LineIndex int `json:"line_index"` + } + var raws []rawRec + for i, r := range records { + raws = append(raws, rawRec{ + Type: "record", RecordType: r.Type, DNameB64: b64(r.Name), + DataB64: []string{b64(r.Address)}, TTL: r.TTL, LineIndex: i + 1, + }) + } + payload := map[string]any{"result": map[string]any{"status": 1, "data": raws}} + b, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// setupDNSVerifyServer starts the in-process SSH server with a `uapi` stub +// that serves $CPSM_DNSVERIFY_FIX for DNS parse_zone, and writes a +// host.yaml whose DESTINATION points at it. The source block is filled +// with the same endpoint only to satisfy config.Load, verify must never +// dial it. +func setupDNSVerifyServer(t *testing.T, fixturePath string) (cfgPath string) { + t.Helper() + tmp := t.TempDir() + stubDir := filepath.Join(tmp, "bin") + if err := os.MkdirAll(stubDir, 0o755); err != nil { + t.Fatal(err) + } + stub := `#!/bin/bash +shift # drop --output=json +case "$1 $2" in + "DNS parse_zone") cat "$CPSM_DNSVERIFY_FIX" ;; + *) echo '{"result":{"status":0,"errors":["stub: unknown uapi call"]}}' ;; +esac +` + if err := os.WriteFile(filepath.Join(stubDir, "uapi"), []byte(stub), 0o755); err != nil { // #nosec G306 -- stub must be executable + t.Fatal(err) + } + t.Setenv("CPSM_DNSVERIFY_FIX", fixturePath) + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HOME", tmp) // keep the TOFU known_hosts out of the real ~/.ssh + + addr := sshtest.NewExecServer(t, tmp) + host, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatal(err) + } + cfgPath = filepath.Join(tmp, "host.yaml") + yaml := fmt.Sprintf(`src: + ip: %[1]s + port: %[2]s + ssh_user: u + ssh_pass: p + timeout: 10s +dest: + ip: %[1]s + port: %[2]s + ssh_user: u + ssh_pass: p + timeout: 10s +`, host, port) + if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + return cfgPath +} + +func readDNSVerifyReport(t *testing.T, path string) accountinventory.DNSVerifyReport { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read report: %v", err) + } + var rep accountinventory.DNSVerifyReport + if err := json.Unmarshal(b, &rep); err != nil { + t.Fatalf("parse report: %v", err) + } + return rep +} + +// --- tests ---------------------------------------------------------------- + +func TestDNSVerifyCmdFlagAndInputErrors(t *testing.T) { + dir := t.TempDir() + if code := runDNSVerifyCmd([]string{"--definitely-not-a-flag"}); code != 2 { + t.Errorf("unknown flag: code = %d, want 2", code) + } + if code := runDNSVerifyCmd([]string{}); code != 1 { + t.Errorf("missing --plan: code = %d, want 1", code) + } + if code := runDNSVerifyCmd([]string{"--plan", filepath.Join(dir, "nope.json")}); code != 1 { + t.Errorf("nonexistent plan: code = %d, want 1", code) + } + badMode := filepath.Join(dir, "badmode.json") + if err := os.WriteFile(badMode, []byte(`{"mode":"inventory-policy"}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runDNSVerifyCmd([]string{"--plan", badMode}); code != 1 { + t.Errorf("wrong mode: code = %d, want 1", code) + } + badVer := filepath.Join(dir, "badver.json") + if err := os.WriteFile(badVer, []byte(`{"mode":"dns-import-plan","format_version":2}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runDNSVerifyCmd([]string{"--plan", badVer}); code != 1 { + t.Errorf("unknown format_version: code = %d, want 1", code) + } +} + +// The stale-plan gate refuses to verify a plan whose embedded input hashes +// no longer match the inventories the operator points at, BEFORE any SSH, +// and without writing a report. +func TestDNSVerifyCmdStalePlanGate(t *testing.T) { + dir := t.TempDir() + srcPath := writeDNSVerifyInventory(t, dir, "src.json", "source", + dnsVerifyZone("example.com", dnsVerifyARec("example.com.", "192.0.2.1"))) + outJSON := filepath.Join(dir, "report.json") + outMD := filepath.Join(dir, "report.md") + + t.Run("hash mismatch", func(t *testing.T) { + planPath := writeDNSVerifyPlanFile(t, t.TempDir(), accountinventory.DNSPlan{ + Mode: "dns-import-plan", FormatVersion: 1, + SourceSHA256: "deadbeef", Zones: []accountinventory.PlanZone{}, + }) + code := runDNSVerifyCmd([]string{"--plan", planPath, "--source", srcPath, + "--output-json", outJSON, "--output-md", outMD}) + if code != 3 { + t.Fatalf("code = %d, want 3 (stale-plan refusal)", code) + } + if _, err := os.Stat(outJSON); !os.IsNotExist(err) { + t.Error("a refused verify must not write a report") + } + }) + + t.Run("plan without embedded hash", func(t *testing.T) { + planPath := writeDNSVerifyPlanFile(t, t.TempDir(), accountinventory.DNSPlan{ + Mode: "dns-import-plan", FormatVersion: 1, Zones: []accountinventory.PlanZone{}, + }) + code := runDNSVerifyCmd([]string{"--plan", planPath, "--source", srcPath, + "--output-json", outJSON, "--output-md", outMD}) + if code != 3 { + t.Fatalf("code = %d, want 3 (nothing to compare against is a refusal, fail-safe)", code) + } + }) +} + +// A plan whose zones all landed in manual_zones has nothing to fetch: the +// command must not need a config or open SSH, but the verdict still gates. +func TestDNSVerifyCmdManualZonesOnlyNeedsNoConfig(t *testing.T) { + dir := t.TempDir() + planPath := writeDNSVerifyPlanFile(t, dir, accountinventory.DNSPlan{ + Mode: "dns-import-plan", FormatVersion: 1, + Zones: []accountinventory.PlanZone{}, + ManualZones: []accountinventory.ManualZone{ + {Zone: "example.com", Reason: "zone missing on destination, create it via WHM/park first, then re-run"}}, + }) + outJSON := filepath.Join(dir, "report.json") + outMD := filepath.Join(dir, "report.md") + + code := runDNSVerifyCmd([]string{"--plan", planPath, "--output-json", outJSON, "--output-md", outMD}) + if code != 0 { + t.Fatalf("code = %d, want 0 (report written, gate only with --fail-on-drift)", code) + } + rep := readDNSVerifyReport(t, outJSON) + if rep.Clean || rep.Summary.ManualZones != 1 { + t.Errorf("clean = %v, manual_zones = %d", rep.Clean, rep.Summary.ManualZones) + } + if _, err := os.Stat(outMD); err != nil { + t.Errorf("markdown report missing: %v", err) + } + + if code := runDNSVerifyCmd([]string{"--plan", planPath, "--output-json", outJSON, + "--output-md", outMD, "--fail-on-drift"}); code != 3 { + t.Errorf("--fail-on-drift with manual zones: code = %d, want 3", code) + } +} + +// End-to-end over the real SSH transport: dns-plan builds the plan from +// crafted inventories, the stub server serves the live zone, dns verify +// re-fetches and reports, clean and drifted variants, sha256 gate green. +func TestDNSVerifyCmdEndToEnd(t *testing.T) { + work := t.TempDir() + srcPath := writeDNSVerifyInventory(t, work, "src.json", "source", + dnsVerifyZone("example.com", dnsVerifyARec("example.com.", "192.0.2.1"))) + destPath := writeDNSVerifyInventory(t, work, "dest.json", "destination", + dnsVerifyZone("example.com")) + planPath := filepath.Join(work, "dns_import_plan.json") + planMD := filepath.Join(work, "dns_import_plan.md") + if code := runInventoryDNSPlanCmd([]string{ + "--source", srcPath, "--destination", destPath, + "--ip-map", "192.0.2.1=198.51.100.1", + "--output-json", planPath, "--output-md", planMD, + }); code != 0 { + t.Fatalf("dns-plan: code = %d, want 0", code) + } + + fixApplied := writeUAPIZoneFixture(t, work, "live_applied.json", + dnsVerifyARec("example.com.", "198.51.100.1")) + fixDrift := writeUAPIZoneFixture(t, work, "live_drift.json", + dnsVerifyARec("example.com.", "9.9.9.9")) + cfgPath := setupDNSVerifyServer(t, fixApplied) + + outJSON := filepath.Join(work, "dns_verify_report.json") + outMD := filepath.Join(work, "dns_verify_report.md") + args := []string{"--plan", planPath, "--config", cfgPath, + "--source", srcPath, "--destination", destPath, + "--output-json", outJSON, "--output-md", outMD} + + if code := runDNSVerifyCmd(append(args, "--fail-on-drift")); code != 0 { + t.Fatalf("clean verify: code = %d, want 0", code) + } + rep := readDNSVerifyReport(t, outJSON) + if !rep.Clean || rep.Summary.Applied != 1 { + t.Fatalf("clean = %v, applied = %d (report: %+v)", rep.Clean, rep.Summary.Applied, rep.Summary) + } + if len(rep.Zones) != 1 || rep.Zones[0].Method != "uapi" { + t.Errorf("zones = %+v, want one fetched via uapi", rep.Zones) + } + if rep.PlanFile != planPath || rep.PlanSHA256 == "" { + t.Errorf("plan provenance missing: file=%q sha=%q", rep.PlanFile, rep.PlanSHA256) + } + if rep.GeneratedAt == "" { + t.Error("generated_at missing") + } + + // Same plan, drifted live zone: report written (exit 0), gate exits 3. + t.Setenv("CPSM_DNSVERIFY_FIX", fixDrift) + if code := runDNSVerifyCmd(args); code != 0 { + t.Fatalf("drifted verify without gate: code = %d, want 0", code) + } + rep = readDNSVerifyReport(t, outJSON) + if rep.Clean || rep.Summary.Drift != 1 { + t.Fatalf("clean = %v, drift = %d", rep.Clean, rep.Summary.Drift) + } + if code := runDNSVerifyCmd(append(args, "--fail-on-drift")); code != 3 { + t.Errorf("drifted verify with gate: code = %d, want 3", code) + } +} diff --git a/cmd/cpanel-self-migration/email_apply_cmd.go b/cmd/cpanel-self-migration/email_apply_cmd.go new file mode 100644 index 00000000..1bc91ca7 --- /dev/null +++ b/cmd/cpanel-self-migration/email_apply_cmd.go @@ -0,0 +1,1280 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/config" + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "github.com/tis24dev/cPanel_self-migration/internal/sshx" +) + +// runEmailApplyCmd implements `cpanel-self-migration email apply`: the +// FIRST config writer of the tool (PR 2B-1). It consumes an offline +// email_apply_plan.json and writes forwarders / default (catch-all) +// addresses onto the DESTINATION account only (sshx.DialDest, the source +// is never dialed, let alone written). +// +// House contract (docs/dev/PR2B_EMAIL_APPLY_DESIGN.md): +// - without --yes-apply-writes: fully offline preview, ZERO connections; +// - backup-or-nothing before the first write, bidirectionally paired +// with the report; +// - per-op freshness guard against a fresh re-list (already_present / +// refused_precondition, no blanket section abort); +// - unconditional per-op verify-after; +// - --rollback : report-driven inverse ops, deletes ONLY the +// tool's own applied creates. +// +// Exit codes: 0 ok; 1 input/runtime/write failure (report still written +// when the run got that far); 2 flags; 3 gated refusal (one or more +// refused_precondition ops, or a refused rollback item). +func runEmailApplyCmd(args []string) int { + fs := flag.NewFlagSet("email apply", flag.ContinueOnError) + planPath := fs.String("plan", "", "path to email_apply_plan.json (required unless --rollback)") + cfgFlag := fs.String("config", "", "path to host.yaml (default: configs/host.yaml or host.yaml)") + yes := fs.Bool("yes-apply-writes", false, "actually write to the DESTINATION (default: fully offline preview, zero connections)") + rollbackPath := fs.String("rollback", "", "path to an email apply backup JSON: roll back that run instead of applying") + reportFlag := fs.String("report", "", "with --rollback: explicit path of the paired apply report (overrides the backup's recorded pairing)") + acceptReportLoss := fs.Bool("accept-report-loss", false, "with --rollback: proceed WITHOUT the paired report, documented degradation: forwarder rollback becomes MANUAL, only default-address restores run") + backupFlag := fs.String("backup", "", "pre-write backup path (default: email_backup__.json in the report directory)") + outJSON := fs.String("output-json", "", "report JSON path (default: email_apply_report.json, or email_rollback_report.json with --rollback)") + outMD := fs.String("output-md", "", "report Markdown path (default: derived from --output-json)") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration email apply --plan email_apply_plan.json [--yes-apply-writes] [--config host.yaml] [--backup PATH]") + fmt.Fprintln(os.Stderr, " cpanel-self-migration email apply --rollback email_backup_....json [--report REPORT.json|--accept-report-loss] [--yes-apply-writes]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + + if *rollbackPath != "" { + if *planPath != "" { + fmt.Fprintln(os.Stderr, "error: --plan and --rollback are mutually exclusive (the rollback is driven by the backup+report pair)") + return 2 + } + return runEmailRollback(*rollbackPath, *reportFlag, *acceptReportLoss, *yes, *cfgFlag, *outJSON, *outMD) + } + if *reportFlag != "" || *acceptReportLoss { + fmt.Fprintln(os.Stderr, "error: --report/--accept-report-loss only apply to --rollback") + return 2 + } + if *planPath == "" { + fmt.Fprintln(os.Stderr, "error: --plan is required") + fs.Usage() + return 1 + } + + plan, err := loadEmailPlanFile(*planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if plan.FormatVersion != 1 { + fmt.Fprintf(os.Stderr, "error: %s: unsupported plan format_version %d (this build understands 1)\n", *planPath, plan.FormatVersion) + return 1 + } + + if !*yes { + printEmailApplyDryRun(plan) + return 0 + } + return runEmailApplyWrites(plan, *planPath, *cfgFlag, *backupFlag, *outJSON, *outMD) +} + +// printEmailApplyDryRun is the offline preview: no config, no SSH, no +// artifact files, safe to run anywhere (house posture for writers). +func printEmailApplyDryRun(plan accountinventory.EmailApplyPlan) { + fmt.Println("email apply, DRY-RUN (fully offline: no connection was opened, nothing was written).") + fmt.Println("NOTE: this preview renders the PLAN-recorded destination state, not the live one -") + fmt.Println("an op shown as `create` may resolve to `already_present` at apply time; the live") + fmt.Println("preview is `email verify`.") + fmt.Println() + writes := 0 + for _, op := range plan.Ops { + switch { + case op.Section == accountinventory.EmailSectionAutoresponders && op.Action == accountinventory.EmailActionCreate: + subject := "" + if op.Autoresponder != nil { + subject = op.Autoresponder.Subject + } + fmt.Printf(" create autoresponder %s (subject %q)\n", op.Key, subject) + writes++ + case op.Section == accountinventory.EmailSectionFilters && op.Action == accountinventory.EmailActionCreate: + nRules := 0 + if op.Filter != nil { + nRules = len(op.Filter.Rules) + } + fmt.Printf(" create filter %s (%d rule(s))\n", op.Key, nRules) + writes++ + case op.Action == accountinventory.EmailActionCreate: + fmt.Printf(" create forwarder %s -> %s\n", op.Key, op.Forward) + writes++ + case op.Section == accountinventory.EmailSectionRouting && op.Action == accountinventory.EmailActionSet: + fmt.Printf(" set routing %s -> %s (plan-time dest: %q)\n", op.Domain, op.Value, op.DestinationValue) + writes++ + case op.Action == accountinventory.EmailActionSet: + fmt.Printf(" set default addr %s -> %s (plan-time dest: %q)\n", op.Domain, op.Value, op.DestinationValue) + writes++ + } + } + if writes == 0 { + fmt.Println(" (no writable ops in this plan)") + } + fmt.Printf("\nplan summary: %d create, %d set, %d skip, %d manual, %d informational\n", + plan.Summary.Create, plan.Summary.Set, plan.Summary.Skip, plan.Summary.Manual, plan.Summary.Informational) + fmt.Println("to apply: re-run with --yes-apply-writes") +} + +// dialEmailDest resolves the config and dials the DESTINATION. +func dialEmailDest(ctx context.Context, cfgFlag string) (*sshx.Client, error) { + path, alternates, err := resolveConfigPath(cfgFlag) + if err != nil { + return nil, err + } + for _, alt := range alternates { + fmt.Fprintf(os.Stderr, "note: multiple host.yaml candidates, using %s (ignoring %s)\n", path, alt) + } + cfg, err := config.Load(path) + if err != nil { + return nil, err + } + if !cfg.DestConfigured() { + return nil, fmt.Errorf("the email commands need the DESTINATION host configured in %s", path) + } + client, err := sshx.DialDest(ctx, cfg, "") + if err != nil { + return nil, fmt.Errorf("dial destination: %w", err) + } + return client, nil +} + +// fetchEmailLiveState re-lists the touched sections on the destination +// (fresh state for the per-op guard) and also archives the verbatim +// responses for the backup. +func fetchEmailLiveState(ctx context.Context, client cpanel.Runner, domains []string, needDefaults bool, arDomains []string) (accountinventory.EmailLiveState, map[string]accountinventory.EmailBackupSection, *accountinventory.EmailBackupSection, map[string]accountinventory.EmailBackupSection) { + live := accountinventory.EmailLiveState{ + ForwardersByDomain: map[string][]accountinventory.NormForwarderEntry{}, + ForwarderListErrors: map[string]string{}, + AutorespondersByDomain: map[string][]accountinventory.NormAutoresponderEntry{}, + AutoresponderListErrors: map[string]string{}, + } + fwdBackup := map[string]accountinventory.EmailBackupSection{} + for _, d := range domains { + entries, raw, err := cpanel.ListForwardersWithRaw(ctx, client, d) + if err != nil { + live.ForwarderListErrors[d] = err.Error() + continue + } + live.ForwardersByDomain[d] = normalizeForwarders(entries, d) + fwdBackup[d] = accountinventory.EmailBackupSection{ + RawUAPIResponse: json.RawMessage(raw), + Forwarders: live.ForwardersByDomain[d], + } + } + var defBackup *accountinventory.EmailBackupSection + if needDefaults { + entries, raw, err := cpanel.ListDefaultAddressesWithRaw(ctx, client) + if err != nil { + live.DefaultsError = err.Error() + } else { + live.DefaultsListed = true + live.Defaults = normalizeDefaults(entries) + defBackup = &accountinventory.EmailBackupSection{ + RawUAPIResponse: json.RawMessage(raw), + Defaults: live.Defaults, + } + } + } + arBackup := map[string]accountinventory.EmailBackupSection{} + for _, d := range arDomains { + entries, listRaw, gets, err := fetchAutorespondersWithRaw(ctx, client, d) + if err != nil { + live.AutoresponderListErrors[d] = err.Error() + continue + } + live.AutorespondersByDomain[d] = entries + arBackup[d] = accountinventory.EmailBackupSection{ + RawUAPIResponse: json.RawMessage(listRaw), + Autoresponders: entries, + RawGetResponses: gets, + } + } + return live, fwdBackup, defBackup, arBackup +} + +// fetchAutoresponderAddress is the targeted form of the fresh re-check +// bound to a SINGLE op: one list call (existence is provable only via the +// list, 2B-2-pre fact 4) plus ONE get for the op's own address when it is +// present. It avoids the O(domain-size) body sweep of +// fetchAutorespondersWithRaw for per-op guard re-checks and verify-afters +// (go-review 2B-2 round 2 finding 2); the returned slice carries at most +// the one entry the caller's EvaluateEmailOp/EmailOutcomePresent looks at. +func fetchAutoresponderAddress(ctx context.Context, client cpanel.Runner, domain, address string) ([]accountinventory.NormAutoresponderEntry, error) { + list, err := cpanel.ListAutoresponders(ctx, client, domain) + if err != nil { + return nil, err + } + for _, a := range list { + addr := a.Email + if !strings.Contains(addr, "@") { + addr = addr + "@" + domain + } + if !strings.EqualFold(strings.TrimSpace(addr), strings.TrimSpace(address)) { + continue + } + entry := accountinventory.NormAutoresponderEntry{ + Email: addr, Domain: domain, Subject: a.Subject, Interval: int(a.Interval), + } + det, err := cpanel.GetAutoresponder(ctx, client, addr) + if err == nil { + entry.From = det.From + entry.Body = det.Body + entry.IsHTML = int(det.IsHTML) + entry.Interval = int(det.Interval) + entry.Start = int64(det.Start) + entry.Stop = int64(det.Stop) + entry.Charset = det.Charset + entry.Subject = det.Subject + entry.BodyCollected = true + } + return []accountinventory.NormAutoresponderEntry{entry}, nil + } + return nil, nil +} + +// fetchAutorespondersWithRaw lists one domain's autoresponders and reads +// each body (existence gated on the list, 2B-2-pre fact 4). A failed +// per-address body read keeps the entry with BodyCollected=false: the +// guard then refuses ops on that address fail-closed instead of guessing. +func fetchAutorespondersWithRaw(ctx context.Context, client cpanel.Runner, domain string) ([]accountinventory.NormAutoresponderEntry, []byte, map[string]json.RawMessage, error) { + list, listRaw, err := cpanel.ListAutorespondersWithRaw(ctx, client, domain) + if err != nil { + return nil, nil, nil, err + } + entries := make([]accountinventory.NormAutoresponderEntry, 0, len(list)) + gets := map[string]json.RawMessage{} + for _, a := range list { + addr := a.Email + if !strings.Contains(addr, "@") { + addr = addr + "@" + domain + } + entry := accountinventory.NormAutoresponderEntry{ + Email: addr, Domain: domain, Subject: a.Subject, Interval: int(a.Interval), + } + det, raw, err := cpanel.GetAutoresponderWithRaw(ctx, client, addr) + if err == nil { + entry.From = det.From + entry.Body = det.Body + entry.IsHTML = int(det.IsHTML) + entry.Interval = int(det.Interval) + entry.Start = int64(det.Start) + entry.Stop = int64(det.Stop) + entry.Charset = det.Charset + // Verbatim get subject, even empty (go-review 2B-2 finding 3). + entry.Subject = det.Subject + entry.BodyCollected = true + gets[addr] = json.RawMessage(raw) + } + entries = append(entries, entry) + } + return entries, listRaw, gets, nil +} + +func normalizeForwarders(entries []cpanel.ForwarderEntry, domain string) []accountinventory.NormForwarderEntry { + out := make([]accountinventory.NormForwarderEntry, 0, len(entries)) + for _, e := range entries { + out = append(out, accountinventory.NormForwarderEntry{Source: e.Dest, Destination: e.Forward, Domain: domain}) + } + return out +} + +func normalizeDefaults(entries []cpanel.DefaultAddressEntry) []accountinventory.DefaultAddressEntry { + out := make([]accountinventory.DefaultAddressEntry, 0, len(entries)) + for _, e := range entries { + out = append(out, accountinventory.DefaultAddressEntry{Domain: e.Domain, DefaultAddress: e.DefaultAddress}) + } + return out +} + +// normalizeRouting converts cpanel MailRoutingEntry to inventory +// EmailRoutingEntry (same conversion as the collector). +func normalizeRouting(entries []cpanel.MailRoutingEntry) []accountinventory.EmailRoutingEntry { + out := make([]accountinventory.EmailRoutingEntry, 0, len(entries)) + for _, d := range entries { + mx := make([]accountinventory.MXRecordEntry, 0, len(d.Entries)) + for _, e := range d.Entries { + mx = append(mx, accountinventory.MXRecordEntry{Priority: int64(e.Priority), Exchange: e.MX}) + } + out = append(out, accountinventory.EmailRoutingEntry{ + Domain: d.Domain, + Routing: d.MXCheck, + Detected: d.Detected, + AlwaysAccept: d.AlwaysAccept != 0, + MXRecords: mx, + }) + } + return out +} + +// splitFilterKey splits a filter op key into account and filter name. +// Mirrors accountinventory.splitFilterKey (unexported). +func splitFilterKey(key string) (account, filtername string) { + parts := strings.SplitN(key, "/", 2) + if len(parts) != 2 { + return "", key + } + account = parts[0] + if account == "(account-level)" { + account = "" + } + return account, parts[1] +} + +// decodeFilterForCmd converts a get_filter result into inventory-shape +// rules and actions. Same logic as the collector's decodeFilterRulesActions +// (kept local to avoid exporting the collector helper). +func decodeFilterForCmd(gf cpanel.GetEmailFilterResult) ([]accountinventory.FilterRule, []accountinventory.FilterAction) { + rules := make([]accountinventory.FilterRule, 0, len(gf.Rules)) + for _, raw := range gf.Rules { + var dec cpanel.FilterRuleDecoded + if err := json.Unmarshal(raw, &dec); err == nil { + rules = append(rules, accountinventory.FilterRule{Part: dec.Part, Match: dec.Match, Opt: dec.Opt, Val: dec.Val}) + } + } + actions := make([]accountinventory.FilterAction, 0, len(gf.Actions)) + for _, raw := range gf.Actions { + var dec cpanel.FilterActionDecoded + if err := json.Unmarshal(raw, &dec); err == nil { + actions = append(actions, accountinventory.FilterAction{Action: dec.Action, Dest: dec.Dest}) + } + } + return rules, actions +} + +// filterRulesToDecoded converts inventory FilterRule to cpanel +// FilterRuleDecoded for the StoreFilter call. +func filterRulesToDecoded(rules []accountinventory.FilterRule) []cpanel.FilterRuleDecoded { + out := make([]cpanel.FilterRuleDecoded, len(rules)) + for i, r := range rules { + out[i] = cpanel.FilterRuleDecoded{Part: r.Part, Match: r.Match, Opt: r.Opt, Val: r.Val} + } + return out +} + +// filterActionsToDecoded converts inventory FilterAction to cpanel +// FilterActionDecoded for the StoreFilter call. +func filterActionsToDecoded(actions []accountinventory.FilterAction) []cpanel.FilterActionDecoded { + out := make([]cpanel.FilterActionDecoded, len(actions)) + for i, a := range actions { + out[i] = cpanel.FilterActionDecoded{Action: a.Action, Dest: a.Dest} + } + return out +} + +// fetchFilterAccount lists all filters for one account scope and enriches +// each with decoded rules via get_filter. Used by the verify command for +// the full-scope live-state picture. +func fetchFilterAccount(ctx context.Context, client cpanel.Runner, account string) ([]accountinventory.NormEmailFilterEntry, error) { + filters, err := cpanel.ListEmailFilters(ctx, client, account) + if err != nil { + return nil, err + } + entries := make([]accountinventory.NormEmailFilterEntry, 0, len(filters)) + for _, f := range filters { + entry := accountinventory.NormEmailFilterEntry{ + Account: account, + FilterName: f.FilterName, + Enabled: f.Enabled != 0, + RuleCount: len(f.Rules), + ActionCount: len(f.Actions), + } + gf, gerr := cpanel.GetEmailFilter(ctx, client, f.FilterName, account) + if gerr == nil { + rules, actions := decodeFilterForCmd(gf) + entry.Rules = rules + entry.Actions = actions + entry.RulesCollected = true + } + entries = append(entries, entry) + } + return entries, nil +} + +// fetchFilterEntry lists filters for one account scope and returns the +// inventory-shape entry for the named filter (with decoded rules via +// get_filter). Returns nil if the filter is absent. This is the per-op +// analogue of fetchAutoresponderAddress: one list call + one get call. +func fetchFilterEntry(ctx context.Context, client cpanel.Runner, account, filtername string) ([]accountinventory.NormEmailFilterEntry, error) { + filters, err := cpanel.ListEmailFilters(ctx, client, account) + if err != nil { + return nil, err + } + for _, f := range filters { + if f.FilterName != filtername { + continue + } + entry := accountinventory.NormEmailFilterEntry{ + Account: account, + FilterName: f.FilterName, + Enabled: f.Enabled != 0, + RuleCount: len(f.Rules), + ActionCount: len(f.Actions), + } + gf, gerr := cpanel.GetEmailFilter(ctx, client, f.FilterName, account) + if gerr == nil { + rules, actions := decodeFilterForCmd(gf) + entry.Rules = rules + entry.Actions = actions + entry.RulesCollected = true + } + return []accountinventory.NormEmailFilterEntry{entry}, nil + } + return nil, nil +} + +// runEmailApplyWrites is the write path: fresh re-list -> per-op guard -> +// backup-or-nothing -> writes with unconditional per-op verify-after -> +// paired report. The report is always written once the run reaches the +// evaluation stage, never sacrificed to the exit code. +func runEmailApplyWrites(plan accountinventory.EmailApplyPlan, planPath, cfgFlag, backupFlag, outJSON, outMD string) int { + planSHA, err := fileSHA256(planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if outJSON == "" { + outJSON = "email_apply_report.json" + } + if outMD == "" { + outMD = deriveMDPath(outJSON) + } + + var createDomains, arDomains, filterAccounts []string + seenDomain := map[string]bool{} + seenARDomain := map[string]bool{} + seenFilterAccount := map[string]bool{} + needDefaults := false + needRouting := false + for _, op := range plan.Ops { + switch { + case op.Section == accountinventory.EmailSectionForwarders && op.Action == accountinventory.EmailActionCreate: + if !seenDomain[op.Domain] { + seenDomain[op.Domain] = true + createDomains = append(createDomains, op.Domain) + } + case op.Section == accountinventory.EmailSectionDefaultAddress && op.Action == accountinventory.EmailActionSet: + needDefaults = true + case op.Section == accountinventory.EmailSectionAutoresponders && op.Action == accountinventory.EmailActionCreate: + if !seenARDomain[op.Domain] { + seenARDomain[op.Domain] = true + arDomains = append(arDomains, op.Domain) + } + case op.Section == accountinventory.EmailSectionFilters && op.Action == accountinventory.EmailActionCreate: + account, _ := splitFilterKey(op.Key) + if !seenFilterAccount[account] { + seenFilterAccount[account] = true + filterAccounts = append(filterAccounts, account) + } + case op.Section == accountinventory.EmailSectionRouting && op.Action == accountinventory.EmailActionSet: + needRouting = true + } + } + sort.Strings(createDomains) + sort.Strings(arDomains) + sort.Strings(filterAccounts) + + ctx := context.Background() + var client *sshx.Client + if len(createDomains) > 0 || needDefaults || len(arDomains) > 0 || len(filterAccounts) > 0 || needRouting { + client, err = dialEmailDest(ctx, cfgFlag) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + defer func() { _ = client.Close() }() + } + + live, fwdBackup, defBackup, arBackup := fetchEmailLiveState(ctx, client, createDomains, needDefaults, arDomains) + + // Filter and routing live state + backup (2B-3 wiring). + live.FiltersByAccount = map[string][]accountinventory.NormEmailFilterEntry{} + live.FilterListErrors = map[string]string{} + filterBackup := map[string]accountinventory.EmailBackupSection{} + for _, acct := range filterAccounts { + rawFilters, raw, err := cpanel.ListEmailFiltersWithRaw(ctx, client, acct) + if err != nil { + live.FilterListErrors[acct] = err.Error() + continue + } + invEntries := make([]accountinventory.NormEmailFilterEntry, 0, len(rawFilters)) + for _, f := range rawFilters { + entry := accountinventory.NormEmailFilterEntry{ + Account: acct, FilterName: f.FilterName, + Enabled: f.Enabled != 0, + RuleCount: len(f.Rules), ActionCount: len(f.Actions), + } + gf, gerr := cpanel.GetEmailFilter(ctx, client, f.FilterName, acct) + if gerr == nil { + rules, actions := decodeFilterForCmd(gf) + entry.Rules = rules + entry.Actions = actions + entry.RulesCollected = true + } + invEntries = append(invEntries, entry) + } + live.FiltersByAccount[acct] = invEntries + filterBackup[acct] = accountinventory.EmailBackupSection{ + RawUAPIResponse: json.RawMessage(raw), + Filters: invEntries, + } + } + var routingBackup *accountinventory.EmailBackupSection + if needRouting { + mxEntries, raw, rerr := cpanel.ListMXsWithRaw(ctx, client) + if rerr != nil { + live.RoutingError = rerr.Error() + } else { + live.RoutingListed = true + live.RoutingEntries = normalizeRouting(mxEntries) + routingBackup = &accountinventory.EmailBackupSection{ + RawUAPIResponse: json.RawMessage(raw), + Routing: live.RoutingEntries, + } + } + } + + // Evaluate every actionable op against the fresh state. + type pendingWrite struct { + op accountinventory.EmailPlanOp + idx int + } + results := make([]accountinventory.EmailOpResult, 0, len(plan.Ops)) + var writes []pendingWrite + for _, op := range plan.Ops { + res := accountinventory.EmailOpResult{EmailPlanOp: op} + switch op.Action { + case accountinventory.EmailActionSkip: + res.Status = accountinventory.EmailOpSkipped + case accountinventory.EmailActionManual: + res.Status = accountinventory.EmailOpManual + case accountinventory.EmailActionCreate, accountinventory.EmailActionSet: + decision, reason := accountinventory.EvaluateEmailOp(op, live, plan.DestinationUser) + switch decision { + case accountinventory.EmailDecisionAlready: + res.Status = accountinventory.EmailOpAlready + case accountinventory.EmailDecisionRefused: + res.Status = accountinventory.EmailOpRefused + res.StatusReason = reason + case accountinventory.EmailDecisionWrite: + res.Status = accountinventory.EmailOpPlanned // upgraded below + writes = append(writes, pendingWrite{op: op, idx: len(results)}) + } + default: + res.Status = accountinventory.EmailOpRefused + res.StatusReason = fmt.Sprintf("unknown plan action %q, malformed or hand-edited plan", op.Action) + } + results = append(results, res) + } + + report := accountinventory.EmailApplyReport{ + Mode: "email-apply-report", FormatVersion: 1, RunMode: "apply", + DestinationUser: plan.DestinationUser, + PlanFile: planPath, PlanSHA256: planSHA, + } + + // Backup-or-nothing: written BEFORE the first write, recording the + // paired report path; a backup failure aborts with zero writes. + if len(writes) > 0 { + backupPath := backupFlag + if backupPath == "" { + backupPath = filepath.Join(filepath.Dir(outJSON), + fmt.Sprintf("email_backup_%s_%s.json", plan.DestinationUser, time.Now().UTC().Format("20060102-150405"))) + } + backup := accountinventory.EmailBackup{ + Mode: "email-apply-backup", FormatVersion: 1, + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + DestinationUser: plan.DestinationUser, + PlanFile: planPath, PlanSHA256: planSHA, + ReportFile: outJSON, + ForwardersByDomain: fwdBackup, + DefaultAddresses: defBackup, + AutorespondersByDomain: arBackup, + FiltersByAccount: filterBackup, + Routing: routingBackup, + } + if err := accountinventory.WriteEmailBackupJSON(backupPath, backup); err != nil { + fmt.Fprintln(os.Stderr, "error: backup-or-nothing, backup write failed, NOTHING was written:", err) + return 1 + } + backupSHA, err := fileSHA256(backupPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error: backup-or-nothing, cannot hash the backup, NOTHING was written:", err) + return 1 + } + report.BackupFile, report.BackupSHA256 = backupPath, backupSHA + fmt.Fprintf(os.Stderr, "wrote %s (pre-write backup)\n", backupPath) + } else { + report.BackupNote = "no write was decided (every op skipped, manual, already present or refused), nothing to back up" + } + + // Execute the writes, each followed by its unconditional verify-after + // (fresh section re-list; `applied` only if the outcome is observable). + for _, w := range writes { + op := w.op + var writeErr error + switch { + case op.Section == accountinventory.EmailSectionForwarders && op.Action == accountinventory.EmailActionCreate: + writeErr = cpanel.AddForwarder(ctx, client, op.Domain, op.Email, op.Forward) + case op.Section == accountinventory.EmailSectionDefaultAddress && op.Action == accountinventory.EmailActionSet: + // set_default_address OVERWRITES the catch-all, so it gets the + // same fresh pre-write re-check as the autoresponder create: + // the run-start batch snapshot is stale by the time this op + // reaches the write loop, and an unconditional set would + // silently destroy a value a human raced in (unrecoverable - + // rollback restores the BACKUP value, not the human's). + // go-review 2B-2 round 2 finding 1. Forwarders deliberately + // have no re-check: add_forwarder is additive and deduped. + freshDefaults, derr := cpanel.ListDefaultAddresses(ctx, client) + if derr != nil { + results[w.idx].Status = accountinventory.EmailOpRefused + results[w.idx].StatusReason = "pre-write default-address re-check failed: " + derr.Error() + continue + } + miniLive := accountinventory.EmailLiveState{ + DefaultsListed: true, + Defaults: normalizeDefaults(freshDefaults), + } + decision, reason := accountinventory.EvaluateEmailOp(op, miniLive, plan.DestinationUser) + switch decision { + case accountinventory.EmailDecisionAlready: + results[w.idx].Status = accountinventory.EmailOpAlready + continue + case accountinventory.EmailDecisionRefused: + results[w.idx].Status = accountinventory.EmailOpRefused + results[w.idx].StatusReason = reason + continue + } + writeErr = cpanel.SetDefaultAddress(ctx, client, op.Domain, op.Value) + case op.Section == accountinventory.EmailSectionAutoresponders && op.Action == accountinventory.EmailActionCreate: + // add_auto_responder UPSERTS (2B-2-pre fact 7), so the batch + // snapshot from the run start is NOT enough: by the time this + // op reaches the front of the sequential write loop the + // address may have gained somebody's autoresponder, and the + // verify-after can only see the post-write state. Collapse + // the guard-to-write window with a fresh per-address re-check + // IMMEDIATELY before the write (go-review 2B-2 finding 1). + freshEntries, ferr := fetchAutoresponderAddress(ctx, client, op.Domain, op.Key) + if ferr != nil { + results[w.idx].Status = accountinventory.EmailOpRefused + results[w.idx].StatusReason = "pre-write autoresponder re-check failed: " + ferr.Error() + continue + } + miniLive := accountinventory.EmailLiveState{ + AutorespondersByDomain: map[string][]accountinventory.NormAutoresponderEntry{op.Domain: freshEntries}, + AutoresponderListErrors: map[string]string{}, + } + decision, reason := accountinventory.EvaluateEmailOp(op, miniLive, plan.DestinationUser) + switch decision { + case accountinventory.EmailDecisionAlready: + results[w.idx].Status = accountinventory.EmailOpAlready + continue + case accountinventory.EmailDecisionRefused: + results[w.idx].Status = accountinventory.EmailOpRefused + results[w.idx].StatusReason = reason + continue + } + a := op.Autoresponder + writeErr = cpanel.AddAutoresponder(ctx, client, op.Domain, op.Email, cpanel.AutoresponderWrite{ + From: a.From, Subject: a.Subject, Body: a.Body, + IsHTML: a.IsHTML, Interval: a.Interval, + Start: a.Start, Stop: a.Stop, Charset: a.Charset, + }) + case op.Section == accountinventory.EmailSectionFilters && op.Action == accountinventory.EmailActionCreate: + // store_filter UPSERTS, same pre-write re-check pattern as + // autoresponder create. + account, filtername := splitFilterKey(op.Key) + freshEntries, ferr := fetchFilterEntry(ctx, client, account, filtername) + if ferr != nil { + results[w.idx].Status = accountinventory.EmailOpRefused + results[w.idx].StatusReason = "pre-write filter re-check failed: " + ferr.Error() + continue + } + miniLive := accountinventory.EmailLiveState{ + FiltersByAccount: map[string][]accountinventory.NormEmailFilterEntry{account: freshEntries}, + FilterListErrors: map[string]string{}, + } + decision, reason := accountinventory.EvaluateEmailOp(op, miniLive, plan.DestinationUser) + switch decision { + case accountinventory.EmailDecisionAlready: + results[w.idx].Status = accountinventory.EmailOpAlready + continue + case accountinventory.EmailDecisionRefused: + results[w.idx].Status = accountinventory.EmailOpRefused + results[w.idx].StatusReason = reason + continue + } + writeErr = cpanel.StoreFilter(ctx, client, filtername, account, + filterRulesToDecoded(op.Filter.Rules), filterActionsToDecoded(op.Filter.Actions)) + case op.Section == accountinventory.EmailSectionRouting && op.Action == accountinventory.EmailActionSet: + // SetMXCheck OVERWRITES, same pre-write re-check as default + // address. + freshMXs, rerr := cpanel.ListMXs(ctx, client) + if rerr != nil { + results[w.idx].Status = accountinventory.EmailOpRefused + results[w.idx].StatusReason = "pre-write routing re-check failed: " + rerr.Error() + continue + } + miniLive := accountinventory.EmailLiveState{ + RoutingListed: true, + RoutingEntries: normalizeRouting(freshMXs), + } + decision, reason := accountinventory.EvaluateEmailOp(op, miniLive, plan.DestinationUser) + switch decision { + case accountinventory.EmailDecisionAlready: + results[w.idx].Status = accountinventory.EmailOpAlready + continue + case accountinventory.EmailDecisionRefused: + results[w.idx].Status = accountinventory.EmailOpRefused + results[w.idx].StatusReason = reason + continue + } + writeErr = cpanel.SetMXCheck(ctx, client, op.Domain, op.Value) + default: + results[w.idx].Status = accountinventory.EmailOpFailed + results[w.idx].StatusReason = fmt.Sprintf("no writer for section %s action %s, malformed plan", op.Section, op.Action) + continue + } + if writeErr != nil { + results[w.idx].Status = accountinventory.EmailOpFailed + results[w.idx].StatusReason = writeErr.Error() + continue + } + fresh, verifyErr := refetchEmailSection(ctx, client, op) + switch { + case verifyErr != nil: + results[w.idx].Status = accountinventory.EmailOpFailed + results[w.idx].StatusReason = "verify-after re-list failed: " + verifyErr.Error() + case accountinventory.EmailOutcomePresent(op, fresh, plan.DestinationUser): + results[w.idx].Status = accountinventory.EmailOpApplied + default: + results[w.idx].Status = accountinventory.EmailOpFailed + results[w.idx].StatusReason = "write reported success but the outcome is not observable in the fresh re-list" + } + } + + report.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + report.Results = results + report.Summary = accountinventory.SummarizeEmailResults(results) + return finishEmailReport(report, outJSON, outMD) +} + +// refetchEmailSection re-lists just the section one op touched, for its +// verify-after. +func refetchEmailSection(ctx context.Context, client cpanel.Runner, op accountinventory.EmailPlanOp) (accountinventory.EmailLiveState, error) { + live := accountinventory.EmailLiveState{ + ForwardersByDomain: map[string][]accountinventory.NormForwarderEntry{}, + ForwarderListErrors: map[string]string{}, + AutorespondersByDomain: map[string][]accountinventory.NormAutoresponderEntry{}, + AutoresponderListErrors: map[string]string{}, + } + switch op.Section { + case accountinventory.EmailSectionForwarders: + entries, err := cpanel.ListForwarders(ctx, client, op.Domain) + if err != nil { + return live, err + } + live.ForwardersByDomain[op.Domain] = normalizeForwarders(entries, op.Domain) + case accountinventory.EmailSectionDefaultAddress: + entries, err := cpanel.ListDefaultAddresses(ctx, client) + if err != nil { + return live, err + } + live.DefaultsListed = true + live.Defaults = normalizeDefaults(entries) + case accountinventory.EmailSectionAutoresponders: + entries, err := fetchAutoresponderAddress(ctx, client, op.Domain, op.Key) + if err != nil { + return live, err + } + live.AutorespondersByDomain[op.Domain] = entries + case accountinventory.EmailSectionFilters: + account, filtername := splitFilterKey(op.Key) + entries, err := fetchFilterEntry(ctx, client, account, filtername) + if err != nil { + return live, err + } + live.FiltersByAccount = map[string][]accountinventory.NormEmailFilterEntry{account: entries} + live.FilterListErrors = map[string]string{} + case accountinventory.EmailSectionRouting: + mxEntries, err := cpanel.ListMXs(ctx, client) + if err != nil { + return live, err + } + live.RoutingListed = true + live.RoutingEntries = normalizeRouting(mxEntries) + } + return live, nil +} + +// finishEmailReport writes both report artifacts and translates the +// summary into the exit code (reports are never sacrificed to it). +func finishEmailReport(report accountinventory.EmailApplyReport, outJSON, outMD string) int { + if err := accountinventory.WriteEmailApplyReportJSON(outJSON, report); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := accountinventory.WriteEmailApplyReportMarkdown(outMD, report); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + s := report.Summary + fmt.Printf("email %s: %d applied, %d already present, %d refused (precondition), %d failed, %d skipped, %d manual\n", + report.RunMode, s.Applied, s.AlreadyPresent, s.Refused, s.Failed, s.Skipped, s.Manual) + fmt.Fprintf(os.Stderr, "wrote %s\nwrote %s\n", outJSON, outMD) + switch { + case s.Failed > 0: + fmt.Fprintln(os.Stderr, "one or more ops FAILED, exiting 1 (see the report)") + return 1 + case s.Refused > 0: + fmt.Fprintln(os.Stderr, "one or more ops were refused by the freshness guard, exiting 3 (re-plan and review)") + return exitDriftGate + } + return 0 +} + +// runEmailRollback drives `email apply --rollback `: the paired +// REPORT is the required input locating the ops that were ACTUALLY +// applied; --accept-report-loss opts into the documented degradation. +func runEmailRollback(backupPath, reportFlag string, acceptLoss, yes bool, cfgFlag, outJSON, outMD string) int { + backup, err := loadEmailBackupFile(backupPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if backup.FormatVersion != 1 { + fmt.Fprintf(os.Stderr, "error: %s: unsupported backup format_version %d\n", backupPath, backup.FormatVersion) + return 1 + } + if outJSON == "" { + outJSON = "email_rollback_report.json" + } + if outMD == "" { + outMD = deriveMDPath(outJSON) + } + + reportPath := reportFlag + if reportPath == "" { + reportPath = backup.ReportFile + } + + var ops []accountinventory.EmailRollbackOp + var manualNotes []string + applyReport, reportErr := loadEmailReportFile(reportPath) + switch { + case reportErr == nil: + // Enforce the recorded backup<->report pairing before driving any + // inverse op: a report that does not pair with this backup would + // delete the WRONG resources on the connected destination (the + // forwarder_remove inverse deletes on exact-pair presence alone). + // The apply report records the backup sha256 and account precisely + // for this check; unused, the recorded pairing is worthless. + backupSHA, shaErr := fileSHA256(backupPath) + if shaErr != nil { + fmt.Fprintln(os.Stderr, "error:", shaErr) + return 1 + } + if applyReport.BackupSHA256 != "" && applyReport.BackupSHA256 != backupSHA { + fmt.Fprintf(os.Stderr, "error: report %s does not pair with backup %s (report records backup sha %s, this backup hashes to %s); refusing to roll back the wrong resources\n", + reportPath, backupPath, applyReport.BackupSHA256, backupSHA) + return 1 + } + if applyReport.DestinationUser != "" && applyReport.DestinationUser != backup.DestinationUser { + fmt.Fprintf(os.Stderr, "error: report/backup destination account mismatch (report %q, backup %q); refusing to roll back\n", + applyReport.DestinationUser, backup.DestinationUser) + return 1 + } + // A report that drives deletes but carries NO backup sha256 cannot be + // proven to pair with this backup; refuse (mirrors the verify command's + // strict no-hash-no-trust stance, for the more destructive operation). + // A zero-write report has no applied ops and rolls back to nothing, so + // it is allowed to pass unhashed. + if applyReport.BackupSHA256 == "" { + for _, r := range applyReport.Results { + if r.Status == accountinventory.EmailOpApplied { + fmt.Fprintf(os.Stderr, "error: report %s carries applied ops but embeds no backup sha256 to prove it pairs with backup %s; refusing to roll back\n", + reportPath, backupPath) + return 1 + } + } + } + ops, err = accountinventory.ComputeEmailRollback(applyReport, backup) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + case acceptLoss: + fmt.Fprintf(os.Stderr, "warning: paired report unavailable (%v), DEGRADED rollback: forwarders/autoresponders/filters become MANUAL (never deleted); only default-address and routing are restored, and those restores overwrite the current destination value WITHOUT a human-divergence check\n", reportErr) + ops, manualNotes = accountinventory.ComputeEmailRollbackDegraded(backup) + default: + fmt.Fprintf(os.Stderr, "error: the paired apply report is a REQUIRED rollback input and could not be read (%v).\n", reportErr) + fmt.Fprintln(os.Stderr, "Pass --report if it lives elsewhere, or --accept-report-loss for the documented degradation (forwarders/autoresponders/filters become manual; default-address and routing restore blind).") + return 1 + } + + if !yes { + fmt.Println("email rollback, DRY-RUN (fully offline: no connection was opened, nothing was written).") + for _, op := range ops { + switch op.Kind { + case accountinventory.EmailRollbackForwarderRemove: + fmt.Printf(" remove forwarder %s -> %s (the tool's own applied create)\n", op.Address, op.Forwarder) + case accountinventory.EmailRollbackDefaultRestore: + fmt.Printf(" restore default %s -> %q (backup value)\n", op.Domain, op.Value) + case accountinventory.EmailRollbackAutoresponderRemove: + fmt.Printf(" remove autoresponder %s (the tool's own applied create)\n", op.Address) + case accountinventory.EmailRollbackFilterRemove: + scope := op.Account + if scope == "" { + scope = "(account-level)" + } + fmt.Printf(" remove filter %q (scope %s) (the tool's own applied create)\n", op.Address, scope) + case accountinventory.EmailRollbackRoutingRestore: + fmt.Printf(" restore routing %s -> %q (backup value)\n", op.Domain, op.Value) + } + } + if len(ops) == 0 { + fmt.Println(" (nothing to roll back: no applied ops in the report)") + } + for _, n := range manualNotes { + fmt.Println(" MANUAL:", n) + } + fmt.Println("to roll back: re-run with --yes-apply-writes") + return 0 + } + + ctx := context.Background() + var client *sshx.Client + if len(ops) > 0 { + client, err = dialEmailDest(ctx, cfgFlag) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + defer func() { _ = client.Close() }() + } + + results := make([]accountinventory.EmailOpResult, 0, len(ops)) + for _, op := range ops { + results = append(results, executeEmailRollbackOp(ctx, client, op, backup.DestinationUser)) + } + for _, n := range manualNotes { + results = append(results, accountinventory.EmailOpResult{ + EmailPlanOp: accountinventory.EmailPlanOp{ + Section: accountinventory.EmailSectionForwarders, + Action: accountinventory.EmailActionManual, + Reason: n, + }, + Status: accountinventory.EmailOpManual, + }) + } + + report := accountinventory.EmailApplyReport{ + Mode: "email-apply-report", FormatVersion: 1, RunMode: "rollback", + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + DestinationUser: backup.DestinationUser, + BackupFile: backupPath, + Results: results, + Summary: accountinventory.SummarizeEmailResults(results), + } + if sha, err := fileSHA256(backupPath); err == nil { + report.BackupSHA256 = sha + } + return finishEmailReport(report, outJSON, outMD) +} + +// executeEmailRollbackOp pre-checks one inverse op against the live state +// (rollback refuses an item whose current state diverged from the +// post-apply state), executes it, and verify-afters it. +func executeEmailRollbackOp(ctx context.Context, client cpanel.Runner, op accountinventory.EmailRollbackOp, destUser string) accountinventory.EmailOpResult { + res := accountinventory.EmailOpResult{EmailPlanOp: accountinventory.EmailPlanOp{ + Section: accountinventory.EmailSectionForwarders, + Action: op.Kind, + Domain: op.Domain, + Key: op.Address, + }} + switch op.Kind { + case accountinventory.EmailRollbackForwarderRemove: + res.Forward = op.Forwarder + entries, err := cpanel.ListForwarders(ctx, client, op.Domain) + if err != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "pre-check re-list failed: "+err.Error() + return res + } + if !cpanel.ForwarderExists(entries, op.Address, op.Forwarder) { + res.Status = accountinventory.EmailOpAlready + res.StatusReason = "pair already absent, nothing to remove" + return res + } + if err := cpanel.DeleteForwarder(ctx, client, op.Address, op.Forwarder); err != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, err.Error() + return res + } + entries, err = cpanel.ListForwarders(ctx, client, op.Domain) + switch { + case err != nil: + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "verify-after re-list failed: "+err.Error() + case cpanel.ForwarderExists(entries, op.Address, op.Forwarder): + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "delete reported success but the pair is still live" + default: + res.Status = accountinventory.EmailOpApplied + } + return res + + case accountinventory.EmailRollbackAutoresponderRemove: + res.Section = accountinventory.EmailSectionAutoresponders + entries, _, _, err := fetchAutorespondersWithRaw(ctx, client, op.Domain) + if err != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "pre-check re-list failed: "+err.Error() + return res + } + var cur accountinventory.NormAutoresponderEntry + present := false + for _, e := range entries { + if strings.EqualFold(strings.TrimSpace(e.Email), strings.TrimSpace(op.Address)) { + cur, present = e, true + break + } + } + if !present { + res.Status = accountinventory.EmailOpAlready + res.StatusReason = "autoresponder already absent, nothing to remove" + return res + } + // Rollback refuses an item whose current state diverged from the + // post-apply state (a human customized it since, never-delete + // somebody's content). An unreadable body also refuses fail-closed. + if !accountinventory.AutoresponderMatchesContent(op.Autoresponder, cur) { + res.Status = accountinventory.EmailOpRefused + res.StatusReason = fmt.Sprintf("the live autoresponder on %s diverged from the content the tool applied, a human changed it since; resolve explicitly", op.Address) + return res + } + if err := cpanel.DeleteAutoresponder(ctx, client, op.Address); err != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, err.Error() + return res + } + entries, _, _, err = fetchAutorespondersWithRaw(ctx, client, op.Domain) + if err != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "verify-after re-list failed: "+err.Error() + return res + } + for _, e := range entries { + if strings.EqualFold(strings.TrimSpace(e.Email), strings.TrimSpace(op.Address)) { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "delete reported success but the autoresponder is still live" + return res + } + } + res.Status = accountinventory.EmailOpApplied + return res + + case accountinventory.EmailRollbackDefaultRestore: + res.Section = accountinventory.EmailSectionDefaultAddress + res.Key = op.Domain + res.Value = op.Value + entries, err := cpanel.ListDefaultAddresses(ctx, client) + if err != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "pre-check re-list failed: "+err.Error() + return res + } + var current string + found := false + for _, e := range entries { + if strings.EqualFold(strings.TrimSpace(e.Domain), op.Domain) { + current, found = e.DefaultAddress, true + break + } + } + if !found { + res.Status, res.StatusReason = accountinventory.EmailOpRefused, "domain no longer appears in the destination default-address list" + return res + } + if accountinventory.DefaultValuesEquivalent(op.Value, current, destUser) { + res.Status = accountinventory.EmailOpAlready + res.StatusReason = "default already carries the backup value" + return res + } + if op.ExpectedCurrent != "" && !accountinventory.DefaultValuesEquivalent(op.ExpectedCurrent, current, destUser) { + res.Status = accountinventory.EmailOpRefused + res.StatusReason = fmt.Sprintf("current default %q diverged from the post-apply state %q, a human changed it since; resolve explicitly", current, op.ExpectedCurrent) + return res + } + if err := cpanel.SetDefaultAddress(ctx, client, op.Domain, op.Value); err != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, err.Error() + return res + } + entries, err = cpanel.ListDefaultAddresses(ctx, client) + if err != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "verify-after re-list failed: "+err.Error() + return res + } + for _, e := range entries { + if strings.EqualFold(strings.TrimSpace(e.Domain), op.Domain) && + accountinventory.DefaultValuesEquivalent(op.Value, e.DefaultAddress, destUser) { + res.Status = accountinventory.EmailOpApplied + return res + } + } + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "restore reported success but the value is not observable in the fresh re-list" + return res + case accountinventory.EmailRollbackFilterRemove: + res.Section = accountinventory.EmailSectionFilters + res.Key = op.Address // filtername + entries, ferr := fetchFilterEntry(ctx, client, op.Account, op.Address) + if ferr != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "pre-check re-list failed: "+ferr.Error() + return res + } + if len(entries) == 0 { + res.Status = accountinventory.EmailOpAlready + res.StatusReason = "filter already absent, nothing to remove" + return res + } + if !accountinventory.FilterMatchesContent(op.Filter, entries[0]) { + res.Status = accountinventory.EmailOpRefused + res.StatusReason = fmt.Sprintf("the live filter %q diverged from the content the tool applied, a human changed it since; resolve explicitly", op.Address) + return res + } + if err := cpanel.DeleteFilter(ctx, client, op.Address, op.Account); err != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, err.Error() + return res + } + entries, ferr = fetchFilterEntry(ctx, client, op.Account, op.Address) + if ferr != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "verify-after re-list failed: "+ferr.Error() + return res + } + if len(entries) > 0 { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "delete reported success but the filter is still live" + return res + } + res.Status = accountinventory.EmailOpApplied + return res + + case accountinventory.EmailRollbackRoutingRestore: + res.Section = accountinventory.EmailSectionRouting + res.Key = op.Domain + res.Value = op.Value + mxEntries, rerr := cpanel.ListMXs(ctx, client) + if rerr != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "pre-check re-list failed: "+rerr.Error() + return res + } + routingEntries := normalizeRouting(mxEntries) + var current string + found := false + for _, e := range routingEntries { + if strings.EqualFold(strings.TrimSpace(e.Domain), op.Domain) { + current, found = e.Routing, true + break + } + } + if !found { + res.Status, res.StatusReason = accountinventory.EmailOpRefused, "domain no longer appears in the routing list" + return res + } + if current == op.Value { + res.Status = accountinventory.EmailOpAlready + res.StatusReason = "routing already carries the backup value" + return res + } + if op.ExpectedCurrent != "" && current != op.ExpectedCurrent { + res.Status = accountinventory.EmailOpRefused + res.StatusReason = fmt.Sprintf("current routing %q diverged from the post-apply state %q, a human changed it since; resolve explicitly", current, op.ExpectedCurrent) + return res + } + if err := cpanel.SetMXCheck(ctx, client, op.Domain, op.Value); err != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, err.Error() + return res + } + mxEntries, rerr = cpanel.ListMXs(ctx, client) + if rerr != nil { + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "verify-after re-list failed: "+rerr.Error() + return res + } + routingEntries = normalizeRouting(mxEntries) + for _, e := range routingEntries { + if strings.EqualFold(strings.TrimSpace(e.Domain), op.Domain) && e.Routing == op.Value { + res.Status = accountinventory.EmailOpApplied + return res + } + } + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "restore reported success but the value is not observable in the fresh re-list" + return res + } + res.Status, res.StatusReason = accountinventory.EmailOpFailed, "unknown rollback op kind "+op.Kind + return res +} + +// loadEmailPlanFile reads and minimally validates an email apply plan. +func loadEmailPlanFile(path string) (accountinventory.EmailApplyPlan, error) { + var p accountinventory.EmailApplyPlan + b, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return p, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(b, &p); err != nil { + return p, fmt.Errorf("parse %s: %w", path, err) + } + if p.Mode != "email-apply-plan" { + return p, fmt.Errorf("%s: not an email apply plan (mode %q)", path, p.Mode) + } + return p, nil +} + +// loadEmailBackupFile reads and minimally validates an email apply backup. +func loadEmailBackupFile(path string) (accountinventory.EmailBackup, error) { + var b accountinventory.EmailBackup + raw, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return b, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(raw, &b); err != nil { + return b, fmt.Errorf("parse %s: %w", path, err) + } + if b.Mode != "email-apply-backup" { + return b, fmt.Errorf("%s: not an email apply backup (mode %q)", path, b.Mode) + } + return b, nil +} + +// loadEmailReportFile reads and minimally validates an email apply report. +func loadEmailReportFile(path string) (accountinventory.EmailApplyReport, error) { + var r accountinventory.EmailApplyReport + if path == "" { + return r, fmt.Errorf("the backup records no paired report path") + } + raw, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return r, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(raw, &r); err != nil { + return r, fmt.Errorf("parse %s: %w", path, err) + } + if r.Mode != "email-apply-report" { + return r, fmt.Errorf("%s: not an email apply report (mode %q)", path, r.Mode) + } + if r.FormatVersion != 1 { + return r, fmt.Errorf("%s: unsupported report format_version %d (this build understands 1)", path, r.FormatVersion) + } + return r, nil +} diff --git a/cmd/cpanel-self-migration/email_apply_cmd_test.go b/cmd/cpanel-self-migration/email_apply_cmd_test.go new file mode 100644 index 00000000..362d5779 --- /dev/null +++ b/cmd/cpanel-self-migration/email_apply_cmd_test.go @@ -0,0 +1,921 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/sshtest" +) + +// --- stateful uapi stub ----------------------------------------------------- + +// emailStubScript is a STATEFUL `uapi` stub: forwarders and default +// addresses live in flat files under $CPSM_EMAIL_STATE, so the E2E tests +// exercise the real write->re-list->verify-after loop, including cPanel's +// dedupe-on-identical-add behavior (2B-pre finding 2). bash 3.2 +// compatible (macOS): no associative arrays. +const emailStubScript = `#!/bin/bash +shift # drop --output=json +mod="$1"; fn="$2"; shift 2 +domain=""; email=""; fwdemail=""; fwdopt=""; address=""; forwarder=""; failmsgs="" +from=""; subject=""; body=""; is_html=""; interval=""; start=""; stop=""; charset="" +for kv in "$@"; do + case "$kv" in + domain=*) domain="${kv#domain=}";; + email=*) email="${kv#email=}";; + fwdemail=*) fwdemail="${kv#fwdemail=}";; + fwdopt=*) fwdopt="${kv#fwdopt=}";; + address=*) address="${kv#address=}";; + forwarder=*) forwarder="${kv#forwarder=}";; + failmsgs=*) failmsgs="${kv#failmsgs=}";; + from=*) from="${kv#from=}";; + subject=*) subject="${kv#subject=}";; + body=*) body="${kv#body=}";; + is_html=*) is_html="${kv#is_html=}";; + interval=*) interval="${kv#interval=}";; + start=*) start="${kv#start=}";; + stop=*) stop="${kv#stop=}";; + charset=*) charset="${kv#charset=}";; + esac +done +S="$CPSM_EMAIL_STATE" +FW="$S/forwarders.txt"; DF="$S/defaults.txt"; AR="$S/autoresponders.txt" +touch "$FW" "$DF" "$AR" +case "$mod $fn" in + "Email list_forwarders") + out=""; first=1 + while IFS='|' read -r a t; do + [ -z "$a" ] && continue + case "$a" in *"@$domain") ;; *) continue;; esac + if [ $first = 1 ]; then first=0; else out="$out,"; fi + out="$out{\"dest\":\"$a\",\"forward\":\"$t\"}" + done < "$FW" + echo "{\"result\":{\"status\":1,\"data\":[$out]}}" + ;; + "Email add_forwarder") + a="$email@$domain" + if ! grep -qF "$a|$fwdemail" "$FW"; then + echo "$a|$fwdemail" >> "$FW" + fi + # race simulation hook: creating the race-default-trigger forwarder + # concurrently rewrites the domain default (a human racing the tool). + if [ "$email" = "race-default-trigger" ]; then + grep -v "^$domain|" "$DF" > "$DF.tmp" || true + mv "$DF.tmp" "$DF" + echo "$domain|important@human.com" >> "$DF" + fi + echo "{\"result\":{\"status\":1,\"data\":{\"forward\":\"$fwdemail\",\"domain\":\"$domain\",\"email\":\"$a\"}}}" + ;; + "Email delete_forwarder") + grep -vF "$address|$forwarder" "$FW" > "$FW.tmp" || true + mv "$FW.tmp" "$FW" + echo '{"result":{"status":1,"data":null}}' + ;; + "Email list_default_address") + out=""; first=1 + while IFS='|' read -r d v; do + [ -z "$d" ] && continue + if [ $first = 1 ]; then first=0; else out="$out,"; fi + out="$out{\"domain\":\"$d\",\"defaultaddress\":\"$v\"}" + done < "$DF" + echo "{\"result\":{\"status\":1,\"data\":[$out]}}" + ;; + "Email set_default_address") + v="$fwdemail" + case "$fwdopt" in + fail) v=":fail: ${failmsgs:-no such address}";; + blackhole) v=":blackhole:";; + esac + grep -v "^$domain|" "$DF" > "$DF.tmp" || true + mv "$DF.tmp" "$DF" + echo "$domain|$v" >> "$DF" + echo "{\"result\":{\"status\":1,\"data\":[{\"dest\":\"$v\",\"domain\":\"$domain\"}]}}" + ;; + "Email list_auto_responders") + out=""; first=1 + while IFS='|' read -r a subj; do + [ -z "$a" ] && continue + case "$a" in *"@$domain") ;; *) continue;; esac + if [ $first = 1 ]; then first=0; else out="$out,"; fi + out="$out{\"email\":\"$a\",\"subject\":\"$subj\"}" + done < "$AR" + echo "{\"result\":{\"status\":1,\"data\":[$out]}}" + ;; + "Email add_auto_responder") + a="$email@$domain" + # race simulation hook: creating aaa-trigger also creates a FOREIGN + # autoresponder on zzz-victim (a human racing the tool mid-run). + if [ "$email" = "aaa-trigger" ]; then + v="zzz-victim@$domain" + printf '%s' "{\"body\":\"Foreign content.\\n\",\"charset\":\"utf-8\",\"from\":\"Human\",\"interval\":1,\"is_html\":0,\"start\":null,\"stop\":null,\"subject\":\"Foreign\"}" > "$S/ar_$v.json" + grep -v "^$v|" "$AR" > "$AR.tmp" || true + mv "$AR.tmp" "$AR" + echo "$v|Foreign" >> "$AR" + fi + body_json=$(printf '%s' "$body" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | awk '{printf "%s\\n", $0}') + [ -z "$body_json" ] && body_json='\n' + printf '%s' "{\"body\":\"$body_json\",\"charset\":\"${charset:-utf-8}\",\"from\":\"$from\",\"interval\":${interval:-0},\"is_html\":${is_html:-0},\"start\":${start:-null},\"stop\":${stop:-null},\"subject\":\"$subject\"}" > "$S/ar_$a.json" + grep -v "^$a|" "$AR" > "$AR.tmp" || true + mv "$AR.tmp" "$AR" + echo "$a|$subject" >> "$AR" + echo '{"result":{"status":1,"data":null}}' + ;; + "Email get_auto_responder") + if [ -f "$S/ar_$email.json" ]; then + echo "{\"result\":{\"status\":1,\"data\":$(cat "$S/ar_$email.json")}}" + else + echo '{"result":{"status":1,"data":{"charset":"utf-8"}}}' + fi + ;; + "Email delete_auto_responder") + grep -v "^$email|" "$AR" > "$AR.tmp" || true + mv "$AR.tmp" "$AR" + rm -f "$S/ar_$email.json" + echo '{"result":{"status":1,"data":null}}' + ;; + *) echo '{"result":{"status":0,"errors":["stub: unknown uapi call"]}}';; +esac +` + +// setupEmailServer starts the in-process SSH server with the stateful +// uapi stub and writes a host.yaml whose DESTINATION points at it. +func setupEmailServer(t *testing.T) (cfgPath, stateDir string) { + t.Helper() + tmp := t.TempDir() + stubDir := filepath.Join(tmp, "bin") + stateDir = filepath.Join(tmp, "state") + for _, d := range []string{stubDir, stateDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(stubDir, "uapi"), []byte(emailStubScript), 0o755); err != nil { // #nosec G306 -- stub must be executable + t.Fatal(err) + } + t.Setenv("CPSM_EMAIL_STATE", stateDir) + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HOME", tmp) // keep the TOFU known_hosts out of the real ~/.ssh + + addr := sshtest.NewExecServer(t, tmp) + host, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatal(err) + } + cfgPath = filepath.Join(tmp, "host.yaml") + yaml := fmt.Sprintf(`src: + ip: %[1]s + port: %[2]s + ssh_user: u + ssh_pass: p + timeout: 10s +dest: + ip: %[1]s + port: %[2]s + ssh_user: u + ssh_pass: p + timeout: 10s +`, host, port) + if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + return cfgPath, stateDir +} + +func setEmailStubState(t *testing.T, stateDir string, forwarders, defaults []string) { + t.Helper() + if err := os.WriteFile(filepath.Join(stateDir, "forwarders.txt"), + []byte(strings.Join(forwarders, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stateDir, "defaults.txt"), + []byte(strings.Join(defaults, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } +} + +func readEmailStubState(t *testing.T, stateDir, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(stateDir, name)) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// buildEmailTestPlan writes crafted inventories and runs the real +// email-plan command, returning the plan path. +func buildEmailTestPlan(t *testing.T, dir string) string { + t.Helper() + src := writeEmailPlanInventory(t, dir, "src.json", "source", "acct", + []accountinventory.NormForwarderEntry{ + {Source: "info@example.com", Destination: "someone@gmail.com", Domain: "example.com"}, + }, + []accountinventory.DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "someone@gmail.com"}, + }) + dest := writeEmailPlanInventory(t, dir, "dest.json", "destination", "acct", nil, + []accountinventory.DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "acct"}, + }) + planPath := filepath.Join(dir, "email_apply_plan.json") + if code := runInventoryEmailPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", planPath, "--output-md", filepath.Join(dir, "email_apply_plan.md"), + }); code != 0 { + t.Fatalf("email-plan: code = %d, want 0", code) + } + return planPath +} + +func readEmailApplyReport(t *testing.T, path string) accountinventory.EmailApplyReport { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read report: %v", err) + } + var r accountinventory.EmailApplyReport + if err := json.Unmarshal(b, &r); err != nil { + t.Fatalf("parse report: %v", err) + } + return r +} + +// --- flag/input errors ------------------------------------------------------ + +func TestEmailApplyCmdFlagAndInputErrors(t *testing.T) { + dir := t.TempDir() + if code := runEmailApplyCmd([]string{"--bogus"}); code != 2 { + t.Errorf("unknown flag: code = %d, want 2", code) + } + if code := runEmailApplyCmd([]string{}); code != 1 { + t.Errorf("missing --plan: code = %d, want 1", code) + } + if code := runEmailApplyCmd([]string{"--plan", "x.json", "--rollback", "y.json"}); code != 2 { + t.Errorf("--plan + --rollback: code = %d, want 2", code) + } + if code := runEmailApplyCmd([]string{"--plan", "x.json", "--report", "y.json"}); code != 2 { + t.Errorf("--report without --rollback: code = %d, want 2", code) + } + badMode := filepath.Join(dir, "badmode.json") + if err := os.WriteFile(badMode, []byte(`{"mode":"dns-import-plan"}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runEmailApplyCmd([]string{"--plan", badMode}); code != 1 { + t.Errorf("wrong mode: code = %d, want 1", code) + } + badVer := filepath.Join(dir, "badver.json") + if err := os.WriteFile(badVer, []byte(`{"mode":"email-apply-plan","format_version":9}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runEmailApplyCmd([]string{"--plan", badVer}); code != 1 { + t.Errorf("unknown format_version: code = %d, want 1", code) + } +} + +// The default dry-run is fully offline: it must succeed with NO host.yaml +// anywhere (chdir into an empty dir) and write NO artifact. +func TestEmailApplyCmdDryRunIsOffline(t *testing.T) { + dir := t.TempDir() + planPath := buildEmailTestPlan(t, dir) + + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + empty := t.TempDir() + if err := os.Chdir(empty); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(cwd) }() + + if code := runEmailApplyCmd([]string{"--plan", planPath}); code != 0 { + t.Fatalf("dry-run: code = %d, want 0 (offline, no config needed)", code) + } + entries, err := os.ReadDir(empty) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Errorf("dry-run wrote artifacts: %v", entries) + } +} + +// --- apply end-to-end ------------------------------------------------------- + +func TestEmailApplyCmdEndToEnd(t *testing.T) { + cfgPath, stateDir := setupEmailServer(t) + dir := t.TempDir() + planPath := buildEmailTestPlan(t, dir) + setEmailStubState(t, stateDir, nil, []string{"example.com|acct"}) + + outJSON := filepath.Join(dir, "email_apply_report.json") + backupPath := filepath.Join(dir, "email_backup_test.json") + + code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }) + if code != 0 { + t.Fatalf("apply: code = %d, want 0", code) + } + + rep := readEmailApplyReport(t, outJSON) + if rep.Summary.Applied != 2 || rep.Summary.Failed != 0 || rep.Summary.Refused != 0 { + t.Fatalf("summary = %+v, want 2 applied", rep.Summary) + } + // The destination state actually changed. + if fw := readEmailStubState(t, stateDir, "forwarders.txt"); !strings.Contains(fw, "info@example.com|someone@gmail.com") { + t.Errorf("forwarder not written: %q", fw) + } + if df := readEmailStubState(t, stateDir, "defaults.txt"); !strings.Contains(df, "example.com|someone@gmail.com") { + t.Errorf("default not written: %q", df) + } + // Bidirectional pairing: report <-> backup. + if rep.BackupFile != backupPath || rep.BackupSHA256 == "" { + t.Errorf("report backup pairing = %q/%q", rep.BackupFile, rep.BackupSHA256) + } + b, err := os.ReadFile(backupPath) + if err != nil { + t.Fatal(err) + } + var backup accountinventory.EmailBackup + if err := json.Unmarshal(b, &backup); err != nil { + t.Fatal(err) + } + if backup.Mode != "email-apply-backup" || backup.ReportFile != outJSON { + t.Errorf("backup = mode %q report %q", backup.Mode, backup.ReportFile) + } + // The backup archived the PRE-write state (default still "acct", + // no forwarders) with the raw responses. + if backup.DefaultAddresses == nil || len(backup.DefaultAddresses.Defaults) != 1 || + backup.DefaultAddresses.Defaults[0].DefaultAddress != "acct" { + t.Errorf("backup defaults = %+v, want the pre-write value acct", backup.DefaultAddresses) + } + if backup.DefaultAddresses != nil && len(backup.DefaultAddresses.RawUAPIResponse) == 0 { + t.Error("backup missing the raw UAPI response") + } + + // Re-run: idempotent convergence, everything already_present, no new + // backup (nothing to write), exit 0. + outJSON2 := filepath.Join(dir, "email_apply_report2.json") + code = runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", outJSON2, + }) + if code != 0 { + t.Fatalf("re-apply: code = %d, want 0", code) + } + rep2 := readEmailApplyReport(t, outJSON2) + if rep2.Summary.AlreadyPresent != 2 || rep2.Summary.Applied != 0 { + t.Fatalf("re-apply summary = %+v, want 2 already_present", rep2.Summary) + } + if rep2.BackupFile != "" || rep2.BackupNote == "" { + t.Errorf("re-apply must not create a backup: file=%q note=%q", rep2.BackupFile, rep2.BackupNote) + } +} + +// The freshness guard: a destination that moved to a THIRD state since +// the plan refuses the op (fail-closed, exit 3) and keeps going. +func TestEmailApplyCmdRefusedPrecondition(t *testing.T) { + cfgPath, stateDir := setupEmailServer(t) + dir := t.TempDir() + planPath := buildEmailTestPlan(t, dir) + // The default moved to a third value; the forwarder address gained a + // different forward. + setEmailStubState(t, stateDir, + []string{"info@example.com|third@party.com"}, + []string{"example.com|third@party.com"}) + + outJSON := filepath.Join(dir, "email_apply_report.json") + code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", filepath.Join(dir, "b.json"), "--output-json", outJSON, + }) + if code != 3 { + t.Fatalf("code = %d, want 3 (gated refusal)", code) + } + rep := readEmailApplyReport(t, outJSON) + if rep.Summary.Refused != 2 || rep.Summary.Applied != 0 { + t.Fatalf("summary = %+v, want 2 refused", rep.Summary) + } + // Nothing was written. + if fw := readEmailStubState(t, stateDir, "forwarders.txt"); strings.Contains(fw, "someone@gmail.com") { + t.Errorf("refused op still wrote: %q", fw) + } +} + +// --- rollback end-to-end ---------------------------------------------------- + +func TestEmailApplyCmdRollbackEndToEnd(t *testing.T) { + cfgPath, stateDir := setupEmailServer(t) + dir := t.TempDir() + planPath := buildEmailTestPlan(t, dir) + setEmailStubState(t, stateDir, nil, []string{"example.com|acct"}) + + outJSON := filepath.Join(dir, "email_apply_report.json") + backupPath := filepath.Join(dir, "email_backup_test.json") + if code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }); code != 0 { + t.Fatalf("apply: code = %d", code) + } + + // Rollback dry-run: offline, prints the inverse ops, writes nothing. + rbJSON := filepath.Join(dir, "email_rollback_report.json") + if code := runEmailApplyCmd([]string{"--rollback", backupPath, "--output-json", rbJSON}); code != 0 { + t.Fatalf("rollback dry-run: code = %d, want 0", code) + } + if _, err := os.Stat(rbJSON); !os.IsNotExist(err) { + t.Error("rollback dry-run must not write a report") + } + + // Real rollback: the pair is deleted, the default restored. + if code := runEmailApplyCmd([]string{ + "--rollback", backupPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", rbJSON, + }); code != 0 { + t.Fatalf("rollback: code = %d, want 0", code) + } + rb := readEmailApplyReport(t, rbJSON) + if rb.RunMode != "rollback" || rb.Summary.Applied != 2 { + t.Fatalf("rollback report = run_mode %q summary %+v", rb.RunMode, rb.Summary) + } + if fw := readEmailStubState(t, stateDir, "forwarders.txt"); strings.Contains(fw, "someone@gmail.com") { + t.Errorf("forwarder not removed: %q", fw) + } + if df := readEmailStubState(t, stateDir, "defaults.txt"); !strings.Contains(df, "example.com|acct") { + t.Errorf("default not restored: %q", df) + } + + // Rollback again: everything already back, already_present, no writes. + rbJSON2 := filepath.Join(dir, "email_rollback_report2.json") + if code := runEmailApplyCmd([]string{ + "--rollback", backupPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", rbJSON2, + }); code != 0 { + t.Fatalf("re-rollback: code = %d, want 0", code) + } + rb2 := readEmailApplyReport(t, rbJSON2) + if rb2.Summary.AlreadyPresent != 2 || rb2.Summary.Applied != 0 { + t.Fatalf("re-rollback summary = %+v, want 2 already_present", rb2.Summary) + } +} + +// A report that does not pair with the backup (wrong sha256) is refused, so a +// mismatched --report can never drive inverse ops against the wrong resources. +func TestEmailApplyCmdRollbackRefusesMismatchedReport(t *testing.T) { + cfgPath, stateDir := setupEmailServer(t) + dir := t.TempDir() + planPath := buildEmailTestPlan(t, dir) + setEmailStubState(t, stateDir, nil, []string{"example.com|acct"}) + + outJSON := filepath.Join(dir, "email_apply_report.json") + backupPath := filepath.Join(dir, "email_backup_test.json") + if code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }); code != 0 { + t.Fatalf("apply: code = %d", code) + } + + // A DIFFERENT backup file (same content + trailing space: still valid + // JSON, different sha256) does not pair with the report's recorded sha. + b, err := os.ReadFile(backupPath) + if err != nil { + t.Fatal(err) + } + otherBackup := filepath.Join(dir, "email_backup_other.json") + if err := os.WriteFile(otherBackup, append(b, ' '), 0o600); err != nil { + t.Fatal(err) + } + + rbJSON := filepath.Join(dir, "email_rollback_report.json") + code := runEmailApplyCmd([]string{ + "--rollback", otherBackup, "--report", outJSON, + "--config", cfgPath, "--yes-apply-writes", "--output-json", rbJSON, + }) + if code != 1 { + t.Fatalf("mismatched report/backup: code = %d, want 1 (refused)", code) + } + if _, err := os.Stat(rbJSON); !os.IsNotExist(err) { + t.Error("a refused rollback must not write a report") + } + // Nothing was rolled back: the applied forwarder is still present. + if fw := readEmailStubState(t, stateDir, "forwarders.txt"); !strings.Contains(fw, "someone@gmail.com") { + t.Errorf("mismatched rollback wrongly modified the destination: %q", fw) + } +} + +// Rollback refuses an item a human changed since the apply (post-apply +// state diverged), exit 3, the other items still proceed. +func TestEmailApplyCmdRollbackRefusesDivergedState(t *testing.T) { + cfgPath, stateDir := setupEmailServer(t) + dir := t.TempDir() + planPath := buildEmailTestPlan(t, dir) + setEmailStubState(t, stateDir, nil, []string{"example.com|acct"}) + + outJSON := filepath.Join(dir, "email_apply_report.json") + backupPath := filepath.Join(dir, "email_backup_test.json") + if code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }); code != 0 { + t.Fatalf("apply: code = %d", code) + } + // A human re-pointed the default after the apply. + setEmailStubState(t, stateDir, + []string{"info@example.com|someone@gmail.com"}, + []string{"example.com|human@choice.com"}) + + rbJSON := filepath.Join(dir, "email_rollback_report.json") + code := runEmailApplyCmd([]string{ + "--rollback", backupPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", rbJSON, + }) + if code != 3 { + t.Fatalf("rollback: code = %d, want 3 (refused item)", code) + } + rb := readEmailApplyReport(t, rbJSON) + if rb.Summary.Refused != 1 || rb.Summary.Applied != 1 { + t.Fatalf("summary = %+v, want 1 refused (default) + 1 applied (forwarder)", rb.Summary) + } + // The human's default was NOT clobbered. + if df := readEmailStubState(t, stateDir, "defaults.txt"); !strings.Contains(df, "human@choice.com") { + t.Errorf("refused rollback clobbered the human's default: %q", df) + } +} + +// Report-loss degradation: without the paired report, rollback is refused +// unless --accept-report-loss opts into the documented degradation +// (default restores only, forwarders manual). +func TestEmailApplyCmdRollbackReportLoss(t *testing.T) { + cfgPath, stateDir := setupEmailServer(t) + dir := t.TempDir() + planPath := buildEmailTestPlan(t, dir) + setEmailStubState(t, stateDir, nil, []string{"example.com|acct"}) + + outJSON := filepath.Join(dir, "email_apply_report.json") + backupPath := filepath.Join(dir, "email_backup_test.json") + if code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", backupPath, "--output-json", outJSON, + }); code != 0 { + t.Fatalf("apply: code = %d", code) + } + if err := os.Remove(outJSON); err != nil { + t.Fatal(err) + } + + rbJSON := filepath.Join(dir, "email_rollback_report.json") + if code := runEmailApplyCmd([]string{ + "--rollback", backupPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", rbJSON, + }); code != 1 { + t.Fatalf("rollback without report: code = %d, want 1 (required input)", code) + } + + if code := runEmailApplyCmd([]string{ + "--rollback", backupPath, "--config", cfgPath, "--yes-apply-writes", + "--accept-report-loss", "--output-json", rbJSON, + }); code != 0 { + t.Fatalf("degraded rollback: code = %d, want 0", code) + } + rb := readEmailApplyReport(t, rbJSON) + if rb.Summary.Applied != 1 || rb.Summary.Manual != 1 { + t.Fatalf("degraded summary = %+v, want 1 applied (default) + 1 manual (forwarders note)", rb.Summary) + } + // The default is back to the backup value; the forwarder was NOT + // touched (never-delete wins without the report). + if df := readEmailStubState(t, stateDir, "defaults.txt"); !strings.Contains(df, "example.com|acct") { + t.Errorf("default not restored: %q", df) + } + if fw := readEmailStubState(t, stateDir, "forwarders.txt"); !strings.Contains(fw, "info@example.com|someone@gmail.com") { + t.Errorf("degraded rollback must NOT delete forwarders: %q", fw) + } +} + +// --- autoresponders (PR 2B-2) ------------------------------------------------- + +// writeEmailPlanInventoryWithAR is writeEmailPlanInventory plus autoresponders. +func writeEmailPlanInventoryWithAR(t *testing.T, dir, name, side, user string, ars []accountinventory.NormAutoresponderEntry) string { + t.Helper() + inv := accountinventory.NewEmptyInventory(user, "192.0.2.1", side) + inv.Domains = []accountinventory.DomainEntry{{Name: "example.com", Type: "main"}} + inv.Autoresponders = ars + inv.DefaultAddresses.Available = true + inv.DefaultAddresses.Items = []accountinventory.DefaultAddressEntry{{Domain: "example.com", DefaultAddress: user}} + inv.EmailRouting.Available = true + inv.EmailFilters.Available = true + b, err := json.MarshalIndent(inv, "", " ") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func testAutoresponderEntry() accountinventory.NormAutoresponderEntry { + return accountinventory.NormAutoresponderEntry{ + Email: "info@example.com", Domain: "example.com", + Subject: "Out of office", From: "Info Desk", + Body: "On vacation.\nBack Monday.\n", IsHTML: 0, Interval: 8, + Charset: "utf-8", BodyCollected: true, + } +} + +// buildEmailAutoresponderPlan builds a plan whose only actionable op is one +// autoresponder create. +func buildEmailAutoresponderPlan(t *testing.T, dir string) string { + t.Helper() + src := writeEmailPlanInventoryWithAR(t, dir, "src_ar.json", "source", "acct", + []accountinventory.NormAutoresponderEntry{testAutoresponderEntry()}) + dest := writeEmailPlanInventoryWithAR(t, dir, "dest_ar.json", "destination", "acct", nil) + planPath := filepath.Join(dir, "email_apply_plan_ar.json") + if code := runInventoryEmailPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", planPath, "--output-md", filepath.Join(dir, "email_apply_plan_ar.md"), + }); code != 0 { + t.Fatalf("email-plan: code = %d, want 0", code) + } + return planPath +} + +func TestEmailApplyCmdAutoresponderEndToEnd(t *testing.T) { + cfgPath, stateDir := setupEmailServer(t) + setEmailStubState(t, stateDir, nil, []string{"example.com|acct"}) + dir := t.TempDir() + planPath := buildEmailAutoresponderPlan(t, dir) + reportPath := filepath.Join(dir, "email_apply_report.json") + + // 1. Apply: the create is written, verified-after, backed up first. + code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", reportPath, "--output-md", filepath.Join(dir, "email_apply_report.md"), + "--backup", filepath.Join(dir, "email_backup.json"), + }) + if code != 0 { + t.Fatalf("apply: code = %d, want 0", code) + } + rep := readEmailApplyReport(t, reportPath) + if rep.Summary.Applied != 1 { + t.Fatalf("summary = %+v, want 1 applied", rep.Summary) + } + if rep.BackupFile == "" { + t.Fatal("a real write must have produced a backup") + } + state := readEmailStubState(t, stateDir, "autoresponders.txt") + if !strings.Contains(state, "info@example.com|Out of office") { + t.Fatalf("stub state = %q, autoresponder not written", state) + } + // The backup archives the pre-write autoresponder section. + bb, err := os.ReadFile(rep.BackupFile) + if err != nil { + t.Fatal(err) + } + var backup accountinventory.EmailBackup + if err := json.Unmarshal(bb, &backup); err != nil { + t.Fatal(err) + } + if _, ok := backup.AutorespondersByDomain["example.com"]; !ok { + t.Errorf("backup lacks the autoresponders section: %+v", backup.AutorespondersByDomain) + } + + // 2. Re-run: converges to already_present, no second backup. + report2 := filepath.Join(dir, "email_apply_report2.json") + if code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", report2, "--output-md", filepath.Join(dir, "email_apply_report2.md"), + }); code != 0 { + t.Fatalf("re-apply: code = %d, want 0", code) + } + rep2 := readEmailApplyReport(t, report2) + if rep2.Summary.AlreadyPresent != 1 || rep2.Summary.Applied != 0 { + t.Fatalf("re-apply summary = %+v, want 1 already_present", rep2.Summary) + } + if rep2.BackupFile != "" { + t.Errorf("no write decided, no backup expected (note %q)", rep2.BackupNote) + } + + // 3. email verify: clean, the create verifies applied. + verifyJSON := filepath.Join(dir, "email_verify.json") + if code := runEmailVerifyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--fail-on-drift", + "--output-json", verifyJSON, "--output-md", filepath.Join(dir, "email_verify.md"), + }); code != 0 { + t.Fatalf("verify: code = %d, want 0 (clean)", code) + } + + // 4. Rollback dry-run: exactly one inverse (the own applied create). + if code := runEmailApplyCmd([]string{"--rollback", rep.BackupFile}); code != 0 { + t.Fatalf("rollback dry-run: code = %d, want 0", code) + } + + // 4b. Apply dry-run renders the autoresponder create with its own + // shape (offline, no config needed). + if code := runEmailApplyCmd([]string{"--plan", planPath}); code != 0 { + t.Fatalf("apply dry-run: code = %d, want 0", code) + } + + // 5. Live rollback: the autoresponder is deleted and verified gone. + rbReport := filepath.Join(dir, "email_rollback_report.json") + if code := runEmailApplyCmd([]string{ + "--rollback", rep.BackupFile, "--config", cfgPath, "--yes-apply-writes", + "--output-json", rbReport, "--output-md", filepath.Join(dir, "email_rollback_report.md"), + }); code != 0 { + t.Fatalf("rollback: code = %d, want 0", code) + } + rb := readEmailApplyReport(t, rbReport) + if rb.Summary.Applied != 1 { + t.Fatalf("rollback summary = %+v, want 1 applied", rb.Summary) + } + state = readEmailStubState(t, stateDir, "autoresponders.txt") + if strings.Contains(state, "info@example.com") { + t.Fatalf("stub state = %q, autoresponder still live after rollback", state) + } +} + +func TestEmailApplyCmdAutoresponderRefusesForeignContent(t *testing.T) { + cfgPath, stateDir := setupEmailServer(t) + setEmailStubState(t, stateDir, nil, []string{"example.com|acct"}) + dir := t.TempDir() + planPath := buildEmailAutoresponderPlan(t, dir) + + // Between plan and apply somebody creates a DIFFERENT autoresponder on + // the same address: the guard must refuse, an add would destroy it. + if err := os.WriteFile(filepath.Join(stateDir, "autoresponders.txt"), + []byte("info@example.com|Somebody's subject\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stateDir, "ar_info@example.com.json"), + []byte(`{"body":"Qualcun altro.\n","charset":"utf-8","from":"X","interval":1,"is_html":0,"start":null,"stop":null,"subject":"Somebody's subject"}`), 0o600); err != nil { + t.Fatal(err) + } + + reportPath := filepath.Join(dir, "email_apply_report.json") + code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", reportPath, "--output-md", filepath.Join(dir, "email_apply_report.md"), + }) + if code != exitDriftGate { + t.Fatalf("apply onto foreign content: code = %d, want %d (refused)", code, exitDriftGate) + } + rep := readEmailApplyReport(t, reportPath) + if rep.Summary.Refused != 1 || rep.Summary.Applied != 0 { + t.Fatalf("summary = %+v, want 1 refused, 0 applied", rep.Summary) + } + // The foreign autoresponder must be UNTOUCHED. + state := readEmailStubState(t, stateDir, "autoresponders.txt") + if !strings.Contains(state, "Somebody's subject") { + t.Fatalf("stub state = %q, the foreign autoresponder was destroyed", state) + } +} + +func TestEmailApplyCmdAutoresponderRollbackRefusesDiverged(t *testing.T) { + cfgPath, stateDir := setupEmailServer(t) + setEmailStubState(t, stateDir, nil, []string{"example.com|acct"}) + dir := t.TempDir() + planPath := buildEmailAutoresponderPlan(t, dir) + reportPath := filepath.Join(dir, "email_apply_report.json") + backupPath := filepath.Join(dir, "email_backup.json") + + if code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", reportPath, "--output-md", filepath.Join(dir, "email_apply_report.md"), + "--backup", backupPath, + }); code != 0 { + t.Fatalf("apply: code = %d, want 0", code) + } + + // A human customizes the applied autoresponder: rollback must refuse + // to delete it (diverged from the post-apply state). + if err := os.WriteFile(filepath.Join(stateDir, "ar_info@example.com.json"), + []byte(`{"body":"Modificato a mano.\n","charset":"utf-8","from":"Info Desk","interval":8,"is_html":0,"start":null,"stop":null,"subject":"Out of office"}`), 0o600); err != nil { + t.Fatal(err) + } + + rbReport := filepath.Join(dir, "email_rollback_report.json") + code := runEmailApplyCmd([]string{ + "--rollback", backupPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", rbReport, "--output-md", filepath.Join(dir, "email_rollback_report.md"), + }) + if code != exitDriftGate { + t.Fatalf("rollback of customized autoresponder: code = %d, want %d (refused)", code, exitDriftGate) + } + state := readEmailStubState(t, stateDir, "autoresponders.txt") + if !strings.Contains(state, "info@example.com") { + t.Fatal("the customized autoresponder was deleted, never-delete violated") + } +} + +func TestEmailApplyCmdAutoresponderMidRunRaceIsRefused(t *testing.T) { + // go-review 2B-2 round 1, finding 1 (HIGH): the batch live snapshot at + // run start can be stale by the time an op reaches the sequential + // write loop, and add_auto_responder UPSERTS, the guard-to-write + // window must be collapsed by a fresh per-address re-check right + // before the write. The stub simulates the race: writing the + // aaa-trigger autoresponder also creates a FOREIGN one on zzz-victim. + cfgPath, stateDir := setupEmailServer(t) + setEmailStubState(t, stateDir, nil, []string{"example.com|acct"}) + dir := t.TempDir() + + trigger := testAutoresponderEntry() + trigger.Email = "aaa-trigger@example.com" + victim := testAutoresponderEntry() + victim.Email = "zzz-victim@example.com" + src := writeEmailPlanInventoryWithAR(t, dir, "src_race.json", "source", "acct", + []accountinventory.NormAutoresponderEntry{trigger, victim}) + dest := writeEmailPlanInventoryWithAR(t, dir, "dest_race.json", "destination", "acct", nil) + planPath := filepath.Join(dir, "email_apply_plan_race.json") + if code := runInventoryEmailPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", planPath, "--output-md", filepath.Join(dir, "email_apply_plan_race.md"), + }); code != 0 { + t.Fatalf("email-plan: code = %d, want 0", code) + } + + reportPath := filepath.Join(dir, "email_apply_report.json") + code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", reportPath, "--output-md", filepath.Join(dir, "email_apply_report.md"), + }) + if code != exitDriftGate { + t.Fatalf("apply with mid-run race: code = %d, want %d (refused)", code, exitDriftGate) + } + rep := readEmailApplyReport(t, reportPath) + if rep.Summary.Applied != 1 || rep.Summary.Refused != 1 { + t.Fatalf("summary = %+v, want 1 applied (trigger) + 1 refused (victim)", rep.Summary) + } + // The foreign autoresponder must be UNTOUCHED, an upsert would have + // silently destroyed it and reported a clean applied. + b, err := os.ReadFile(filepath.Join(stateDir, "ar_zzz-victim@example.com.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "Foreign content.") { + t.Fatalf("foreign autoresponder content destroyed: %s", b) + } +} + +func TestEmailApplyCmdDefaultSetMidRunRaceIsRefused(t *testing.T) { + // go-review 2B-2 round 2, finding 1 (HIGH): set_default_address + // OVERWRITES the catch-all, so it needs the same fresh pre-write + // re-check as the autoresponder create, the run-start batch snapshot + // is stale by the time the default op reaches the write loop. The + // stub simulates the race: writing the race-default-trigger forwarder + // concurrently sets the domain default to a human's value. + cfgPath, stateDir := setupEmailServer(t) + setEmailStubState(t, stateDir, nil, []string{"example.com|acct"}) + dir := t.TempDir() + + src := writeEmailPlanInventory(t, dir, "src_dr.json", "source", "acct", + []accountinventory.NormForwarderEntry{ + {Source: "race-default-trigger@example.com", Destination: "x@y.com", Domain: "example.com"}, + }, + []accountinventory.DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "catchall@src.com"}, + }) + dest := writeEmailPlanInventory(t, dir, "dest_dr.json", "destination", "acct", nil, + []accountinventory.DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "acct"}, + }) + planPath := filepath.Join(dir, "email_apply_plan_dr.json") + if code := runInventoryEmailPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", planPath, "--output-md", filepath.Join(dir, "email_apply_plan_dr.md"), + }); code != 0 { + t.Fatalf("email-plan: code = %d, want 0", code) + } + + reportPath := filepath.Join(dir, "email_apply_report.json") + code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--output-json", reportPath, "--output-md", filepath.Join(dir, "email_apply_report.md"), + }) + if code != exitDriftGate { + t.Fatalf("apply with mid-run default race: code = %d, want %d (refused)", code, exitDriftGate) + } + rep := readEmailApplyReport(t, reportPath) + if rep.Summary.Applied != 1 || rep.Summary.Refused != 1 { + t.Fatalf("summary = %+v, want 1 applied (forwarder) + 1 refused (default)", rep.Summary) + } + // The human's default must be UNTOUCHED, an unconditional set would + // have silently overwritten it (and rollback would restore the backup + // value, not the human's, making the loss unrecoverable). + state := readEmailStubState(t, stateDir, "defaults.txt") + if !strings.Contains(state, "important@human.com") { + t.Fatalf("stub defaults = %q, the human's default was destroyed", state) + } +} diff --git a/cmd/cpanel-self-migration/email_verify_cmd.go b/cmd/cpanel-self-migration/email_verify_cmd.go new file mode 100644 index 00000000..afd62334 --- /dev/null +++ b/cmd/cpanel-self-migration/email_verify_cmd.go @@ -0,0 +1,232 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "context" + "flag" + "fmt" + "os" + "sort" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" +) + +// runEmailVerifyCmd implements `cpanel-self-migration email verify`: +// re-list the DESTINATION forwarders and default addresses named by an +// email_apply_plan.json (read-only) and report, per planned op, whether +// the live state matches the plan (PR 2B-1, mirror of `dns verify`). The +// source host is never dialed. +// +// Exit codes: 0 verify ran and reports were written (even with drift); +// 1 invalid input, config/SSH-dial failure or write failure; 2 flag +// parsing; 3 gated refusal (stale plan, or --fail-on-drift + not clean). +func runEmailVerifyCmd(args []string) int { + fs := flag.NewFlagSet("email verify", flag.ContinueOnError) + planPath := fs.String("plan", "", "path to email_apply_plan.json (required)") + cfgFlag := fs.String("config", "", "path to host.yaml (default: configs/host.yaml or host.yaml)") + srcInv := fs.String("source", "", "optional source inventory JSON: refuse the plan unless its embedded source_sha256 matches this file") + destInv := fs.String("destination", "", "optional destination inventory JSON: refuse the plan unless its embedded destination_sha256 matches this file") + outJSON := fs.String("output-json", "email_verify_report.json", "verify report JSON output path") + outMD := fs.String("output-md", "email_verify_report.md", "verify report Markdown output path") + failOnDrift := fs.Bool("fail-on-drift", false, "exit 3 unless the verify verdict is clean (no pending, drift, unavailable ops or manual sections)") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration email verify --plan email_apply_plan.json [--config host.yaml] [--source src.json] [--destination dest.json] [--fail-on-drift]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if *planPath == "" { + fmt.Fprintln(os.Stderr, "error: --plan is required") + fs.Usage() + return 1 + } + + plan, err := loadEmailPlanFile(*planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if plan.FormatVersion != 1 { + fmt.Fprintf(os.Stderr, "error: %s: unsupported plan format_version %d (this build understands 1)\n", *planPath, plan.FormatVersion) + return 1 + } + + // Stale-plan gate (before any SSH): identical semantics to dns verify. + for _, in := range []struct{ flagName, path, embedded string }{ + {"--source", *srcInv, plan.SourceSHA256}, + {"--destination", *destInv, plan.DestinationSHA256}, + } { + if in.path == "" { + continue + } + sum, err := fileSHA256(in.path) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if in.embedded == "" { + fmt.Fprintf(os.Stderr, "refused: the plan embeds no sha256 for %s, cannot prove it was built from %s (rebuild the plan)\n", in.flagName, in.path) + return exitDriftGate + } + if sum != in.embedded { + fmt.Fprintf(os.Stderr, "refused: stale plan, %s %s hashes to %s but the plan was built from %s\n", in.flagName, in.path, sum, in.embedded) + return exitDriftGate + } + } + + planSHA, err := fileSHA256(*planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + // Domains verify must re-list: every 2B-1-section op + informational. + domainSet := map[string]bool{} + arDomainSet := map[string]bool{} + filterAccountSet := map[string]bool{} + needDefaults := false + needRouting := false + for _, op := range plan.Ops { + switch op.Section { + case accountinventory.EmailSectionForwarders: + domainSet[op.Domain] = true + case accountinventory.EmailSectionDefaultAddress: + needDefaults = true + case accountinventory.EmailSectionAutoresponders: + arDomainSet[op.Domain] = true + case accountinventory.EmailSectionFilters: + account, _ := splitFilterKey(op.Key) + filterAccountSet[account] = true + case accountinventory.EmailSectionRouting: + needRouting = true + } + } + for _, info := range plan.Informational { + switch info.Section { + case accountinventory.EmailSectionForwarders: + domainSet[info.Domain] = true + case accountinventory.EmailSectionDefaultAddress: + needDefaults = true + case accountinventory.EmailSectionAutoresponders: + arDomainSet[info.Domain] = true + case accountinventory.EmailSectionFilters: + account, _ := splitFilterKey(info.Key) + filterAccountSet[account] = true + case accountinventory.EmailSectionRouting: + needRouting = true + } + } + domains := make([]string, 0, len(domainSet)) + for d := range domainSet { + domains = append(domains, d) + } + sort.Strings(domains) + arDomains := make([]string, 0, len(arDomainSet)) + for d := range arDomainSet { + arDomains = append(arDomains, d) + } + sort.Strings(arDomains) + filterAccounts := make([]string, 0, len(filterAccountSet)) + for a := range filterAccountSet { + filterAccounts = append(filterAccounts, a) + } + sort.Strings(filterAccounts) + + // Fetch the live state, only when there is something to re-list. A + // manual-only plan needs no config and opens no SSH. + live := accountinventory.EmailLiveState{ + ForwardersByDomain: map[string][]accountinventory.NormForwarderEntry{}, + ForwarderListErrors: map[string]string{}, + AutorespondersByDomain: map[string][]accountinventory.NormAutoresponderEntry{}, + AutoresponderListErrors: map[string]string{}, + } + if len(domains) > 0 || needDefaults || len(arDomains) > 0 || len(filterAccounts) > 0 || needRouting { + ctx := context.Background() + client, err := dialEmailDest(ctx, *cfgFlag) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + defer func() { _ = client.Close() }() + for _, d := range domains { + entries, err := cpanel.ListForwarders(ctx, client, d) + if err != nil { + live.ForwarderListErrors[d] = err.Error() + continue + } + live.ForwardersByDomain[d] = normalizeForwarders(entries, d) + } + if needDefaults { + entries, err := cpanel.ListDefaultAddresses(ctx, client) + if err != nil { + live.DefaultsError = err.Error() + } else { + live.DefaultsListed = true + live.Defaults = normalizeDefaults(entries) + } + } + for _, d := range arDomains { + entries, _, _, err := fetchAutorespondersWithRaw(ctx, client, d) + if err != nil { + live.AutoresponderListErrors[d] = err.Error() + continue + } + live.AutorespondersByDomain[d] = entries + } + live.FiltersByAccount = map[string][]accountinventory.NormEmailFilterEntry{} + live.FilterListErrors = map[string]string{} + for _, acct := range filterAccounts { + entries, err := fetchFilterAccount(ctx, client, acct) + if err != nil { + live.FilterListErrors[acct] = err.Error() + continue + } + live.FiltersByAccount[acct] = entries + } + if needRouting { + mxEntries, err := cpanel.ListMXs(ctx, client) + if err != nil { + live.RoutingError = err.Error() + } else { + live.RoutingListed = true + live.RoutingEntries = normalizeRouting(mxEntries) + } + } + } + + rep := accountinventory.VerifyEmailPlan(plan, live) + rep.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + rep.PlanFile = *planPath + rep.PlanSHA256 = planSHA + + if err := accountinventory.WriteEmailVerifyJSON(*outJSON, rep); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := accountinventory.WriteEmailVerifyMarkdown(*outMD, rep); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + verdict := "CLEAN" + if !rep.Clean { + verdict = "NOT CLEAN" + } + fmt.Printf("email verify: %s, %d applied, %d unchanged, %d pending, %d drift, %d manual review, %d not checked, %d unavailable, %d untracked; %d manual section(s)\n", + verdict, rep.Summary.Applied, rep.Summary.Unchanged, rep.Summary.Pending, rep.Summary.Drift, + rep.Summary.ManualReview, rep.Summary.NotChecked, rep.Summary.Unavailable, + rep.Summary.Untracked, rep.Summary.ManualSections) + fmt.Fprintf(os.Stderr, "wrote %s\n", *outJSON) + fmt.Fprintf(os.Stderr, "wrote %s\n", *outMD) + + if *failOnDrift && !rep.Clean { + fmt.Fprintln(os.Stderr, "verdict not clean and --fail-on-drift is set, exiting 3") + return exitDriftGate + } + return 0 +} diff --git a/cmd/cpanel-self-migration/email_verify_cmd_test.go b/cmd/cpanel-self-migration/email_verify_cmd_test.go new file mode 100644 index 00000000..fe862be0 --- /dev/null +++ b/cmd/cpanel-self-migration/email_verify_cmd_test.go @@ -0,0 +1,123 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +func readEmailVerifyReport(t *testing.T, path string) accountinventory.EmailVerifyReport { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read report: %v", err) + } + var r accountinventory.EmailVerifyReport + if err := json.Unmarshal(b, &r); err != nil { + t.Fatalf("parse report: %v", err) + } + return r +} + +func TestEmailVerifyCmdFlagAndInputErrors(t *testing.T) { + dir := t.TempDir() + if code := runEmailVerifyCmd([]string{"--bogus"}); code != 2 { + t.Errorf("unknown flag: code = %d, want 2", code) + } + if code := runEmailVerifyCmd([]string{}); code != 1 { + t.Errorf("missing --plan: code = %d, want 1", code) + } + badMode := filepath.Join(dir, "badmode.json") + if err := os.WriteFile(badMode, []byte(`{"mode":"dns-import-plan"}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runEmailVerifyCmd([]string{"--plan", badMode}); code != 1 { + t.Errorf("wrong mode: code = %d, want 1", code) + } +} + +// The stale-plan gate refuses before any SSH and writes no report. +func TestEmailVerifyCmdStalePlanGate(t *testing.T) { + dir := t.TempDir() + src := writeEmailPlanInventory(t, dir, "src.json", "source", "acct", nil, nil) + outJSON := filepath.Join(dir, "report.json") + + plan := accountinventory.EmailApplyPlan{ + Mode: "email-apply-plan", FormatVersion: 1, + SourceSHA256: "deadbeef", Ops: []accountinventory.EmailPlanOp{}, + } + planPath := filepath.Join(dir, "plan.json") + if err := accountinventory.WriteEmailPlanJSON(planPath, plan); err != nil { + t.Fatal(err) + } + code := runEmailVerifyCmd([]string{"--plan", planPath, "--source", src, + "--output-json", outJSON, "--output-md", filepath.Join(dir, "report.md")}) + if code != 3 { + t.Fatalf("code = %d, want 3 (stale-plan refusal)", code) + } + if _, err := os.Stat(outJSON); !os.IsNotExist(err) { + t.Error("a refused verify must not write a report") + } + + // A plan with no embedded hash cannot be validated, fail-safe refusal. + plan.SourceSHA256 = "" + if err := accountinventory.WriteEmailPlanJSON(planPath, plan); err != nil { + t.Fatal(err) + } + if code := runEmailVerifyCmd([]string{"--plan", planPath, "--source", src, + "--output-json", outJSON, "--output-md", filepath.Join(dir, "report.md")}); code != 3 { + t.Errorf("plan without embedded hash: code = %d, want 3", code) + } +} + +// End-to-end: pending before the apply (gates with --fail-on-drift), +// clean after it. +func TestEmailVerifyCmdEndToEnd(t *testing.T) { + cfgPath, stateDir := setupEmailServer(t) + dir := t.TempDir() + planPath := buildEmailTestPlan(t, dir) + setEmailStubState(t, stateDir, nil, []string{"example.com|acct"}) + + outJSON := filepath.Join(dir, "email_verify_report.json") + outMD := filepath.Join(dir, "email_verify_report.md") + + code := runEmailVerifyCmd([]string{"--plan", planPath, "--config", cfgPath, + "--output-json", outJSON, "--output-md", outMD}) + if code != 0 { + t.Fatalf("verify (pre-apply): code = %d, want 0 (report written, gate only with --fail-on-drift)", code) + } + rep := readEmailVerifyReport(t, outJSON) + if rep.Clean || rep.Summary.Pending != 2 { + t.Fatalf("pre-apply: clean = %v, pending = %d, want NOT clean with 2 pending", rep.Clean, rep.Summary.Pending) + } + if code := runEmailVerifyCmd([]string{"--plan", planPath, "--config", cfgPath, + "--output-json", outJSON, "--output-md", outMD, "--fail-on-drift"}); code != 3 { + t.Errorf("--fail-on-drift pre-apply: code = %d, want 3", code) + } + + // Apply, then verify again: clean. + if code := runEmailApplyCmd([]string{ + "--plan", planPath, "--config", cfgPath, "--yes-apply-writes", + "--backup", filepath.Join(dir, "b.json"), "--output-json", filepath.Join(dir, "apply.json"), + }); code != 0 { + t.Fatalf("apply: code = %d", code) + } + code = runEmailVerifyCmd([]string{"--plan", planPath, "--config", cfgPath, + "--output-json", outJSON, "--output-md", outMD, "--fail-on-drift"}) + if code != 0 { + t.Fatalf("verify (post-apply): code = %d, want 0 (clean)", code) + } + rep = readEmailVerifyReport(t, outJSON) + if !rep.Clean || rep.Summary.Applied != 2 { + t.Fatalf("post-apply: clean = %v, applied = %d", rep.Clean, rep.Summary.Applied) + } + if _, err := os.Stat(outMD); err != nil { + t.Errorf("markdown report missing: %v", err) + } +} diff --git a/cmd/cpanel-self-migration/inventory_checklist_cmd.go b/cmd/cpanel-self-migration/inventory_checklist_cmd.go new file mode 100644 index 00000000..2609ad52 --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_checklist_cmd.go @@ -0,0 +1,286 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +// exitNotReadyGate is the process exit code emitted when +// --fail-on-not-ready is set and the overall status is neither +// READY_TO_CUTOVER nor READY_WITH_MANUAL_NOTES. Same CLI-contract rule as +// exitBlockedGate: tests assert the literal value on purpose. +const exitNotReadyGate = 3 + +// runInventoryChecklistCmd implements `cpanel-self-migration inventory +// checklist`: a fully offline composition of the pipeline's artifacts into +// the operator-facing migration checklist. It never connects to any server +// and never writes anything but the two report files. Exit codes: +// 0 = checklist generated (manual actions/blockers are findings, not +// process errors), 1 = invalid input or write failure, 2 = unparsable +// flags, 3 = --fail-on-not-ready was set and the overall status is not +// READY_* (reports are still fully written first). +func runInventoryChecklistCmd(args []string) int { + fs := flag.NewFlagSet("inventory checklist", flag.ContinueOnError) + source := fs.String("source", "", "path to the source inventory JSON (required)") + destination := fs.String("destination", "", "path to the destination inventory JSON (required)") + diffPath := fs.String("diff", "", "path to the inventory_diff.json (required)") + policyPath := fs.String("policy", "", "path to the policy_report.json (required)") + planPath := fs.String("dns-plan", "", "optional dns_import_plan.json for DNS expected-difference detection") + reportPath := fs.String("migration-report", "", "optional report.json from an --apply run, used as migration evidence") + acceptancesPath := fs.String("acceptances", "", "optional acceptances.json with operator-accepted manual actions (bound to stable action keys)") + outJSON := fs.String("output-json", "migration_checklist.json", "path for the machine-readable checklist") + outMD := fs.String("output-md", "migration_checklist.md", "path for the operator-facing checklist") + failOnNotReady := fs.Bool("fail-on-not-ready", false, "exit with code 3 unless the overall status is READY_TO_CUTOVER or READY_WITH_MANUAL_NOTES (for CI gating)") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration inventory checklist --source SRC.json --destination DEST.json --diff DIFF.json --policy POLICY.json [--dns-plan PLAN.json] [--migration-report REPORT.json] [--acceptances ACC.json] [--output-json PATH] [--output-md PATH] [--fail-on-not-ready]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if *source == "" || *destination == "" || *diffPath == "" || *policyPath == "" { + fmt.Fprintln(os.Stderr, "error: --source, --destination, --diff and --policy are required") + fs.Usage() + return 1 + } + + srcInv, err := loadInventoryFile(*source) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + destInv, err := loadInventoryFile(*destination) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + d, err := loadDiffFile(*diffPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + p, err := loadPolicyFile(*policyPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + var plan *accountinventory.DNSPlan + if *planPath != "" { + pl, err := loadDNSPlanFile(*planPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + plan = &pl + } + var report *accountinventory.MigrationReportInfo + if *reportPath != "" { + r, err := loadMigrationReportFile(*reportPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + report = &r + } + var acceptances []accountinventory.OperatorAcceptance + var acceptanceWarning string + if *acceptancesPath != "" { + accs, warning, err := loadAcceptancesFile(*acceptancesPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + acceptances, acceptanceWarning = accs, warning + } + + // Input refs (file + raw-byte sha256) are computed BEFORE building the + // checklist: the engine verifies the provenance chain against them. + var refs accountinventory.ChecklistInputs + if refs.SourceInventory, err = checklistInputRef(*source); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if refs.DestinationInventory, err = checklistInputRef(*destination); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if refs.Diff, err = checklistInputRef(*diffPath); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if refs.Policy, err = checklistInputRef(*policyPath); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if *planPath != "" { + if refs.DNSPlan, err = checklistInputRef(*planPath); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + } + if *reportPath != "" { + if refs.MigrationReport, err = checklistInputRef(*reportPath); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + } + if *acceptancesPath != "" { + if refs.Acceptances, err = checklistInputRef(*acceptancesPath); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + } + + // One single "now": the SSL-validity reference time and the report's + // own Generated timestamp must agree for auditability. + now := time.Now().UTC() + c := accountinventory.BuildChecklist(accountinventory.ChecklistInput{ + Source: srcInv, Destination: destInv, Diff: d, Policy: p, + DNSPlan: plan, MigrationReport: report, Acceptances: acceptances, + InputRefs: refs, + Now: now, + }) + c.GeneratedAt = now.Format(time.RFC3339) + if acceptanceWarning != "" { + c.Warnings = append(c.Warnings, acceptanceWarning) + } + + if err := accountinventory.WriteChecklistJSON(*outJSON, c); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := accountinventory.WriteChecklistMarkdown(*outMD, c); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + blocking := 0 + for _, a := range c.ManualActions { + if a.BlockingCutover { + blocking++ + } + } + fmt.Printf("migration checklist: %s - %d blocked section(s), %d manual action(s) (%d blocking), %d expected difference(s)\n", + c.OverallStatus, c.Summary.Blocked, c.Summary.ManualActions, blocking, c.Summary.ExpectedDifferences) + fmt.Fprintf(os.Stderr, "wrote %s\nwrote %s\n", *outJSON, *outMD) + + if *failOnNotReady && + c.OverallStatus != accountinventory.OverallReadyToCutover && + c.OverallStatus != accountinventory.OverallReadyWithManualNotes { + fmt.Fprintf(os.Stderr, "fail-on-not-ready: overall status is %s, exiting %d\n", c.OverallStatus, exitNotReadyGate) + return exitNotReadyGate + } + return 0 +} + +// checklistInputRef hashes one provided input file (raw bytes, same +// stale-input defense as the DNS plan). +func checklistInputRef(path string) (accountinventory.ChecklistInputRef, error) { + sum, err := fileSHA256(path) + if err != nil { + return accountinventory.ChecklistInputRef{}, err + } + return accountinventory.ChecklistInputRef{File: path, SHA256: sum, Present: true}, nil +} + +// loadDNSPlanFile reads and minimally validates a DNS import plan JSON. +func loadDNSPlanFile(path string) (accountinventory.DNSPlan, error) { + var p accountinventory.DNSPlan + b, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return p, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(b, &p); err != nil { + return p, fmt.Errorf("parse %s: %w", path, err) + } + if p.Mode != "dns-import-plan" { + return p, fmt.Errorf("%s: not a DNS import plan (mode %q)", path, p.Mode) + } + return p, nil +} + +// loadAcceptancesFile reads and validates an acceptances.json. Structural +// problems (wrong mode, missing required fields, bad JSON) are hard errors. +// When the file names the checklist the operator reviewed (checklist_file), +// its sha256 is verified STRICTLY: a mismatch - or an unreadable file - +// rejects every acceptance (returned as a warning with zero entries), never +// silently applies them. The checklist is still generated either way. +func loadAcceptancesFile(path string) ([]accountinventory.OperatorAcceptance, string, error) { + var f accountinventory.AcceptanceFile + b, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return nil, "", fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(b, &f); err != nil { + return nil, "", fmt.Errorf("parse %s: %w", path, err) + } + if f.Mode != accountinventory.AcceptanceFileMode { + return nil, "", fmt.Errorf("%s: not an operator acceptance file (mode %q)", path, f.Mode) + } + if f.FormatVersion != 1 { + return nil, "", fmt.Errorf("%s: unsupported acceptance format_version %d (this build understands 1)", path, f.FormatVersion) + } + if f.ChecklistSHA256 == "" { + return nil, "", fmt.Errorf("%s: checklist_sha256 is required (the sha256 of the checklist file the operator reviewed)", path) + } + for i, a := range f.Acceptances { + switch { + case a.ActionKey == "": + return nil, "", fmt.Errorf("%s: acceptance %d: action_key is required", path, i+1) + case a.Reason == "": + return nil, "", fmt.Errorf("%s: acceptance %d (%s): reason is required", path, i+1, a.ActionKey) + case a.AcceptedBy == "": + return nil, "", fmt.Errorf("%s: acceptance %d (%s): accepted_by is required", path, i+1, a.ActionKey) + case a.AcceptedAt == "": + return nil, "", fmt.Errorf("%s: acceptance %d (%s): accepted_at is required", path, i+1, a.ActionKey) + } + } + if f.ChecklistFile != "" { + // A relative checklist_file is resolved against the acceptance + // file's own directory, so the pair can be moved together. + checklistPath := f.ChecklistFile + if !filepath.IsAbs(checklistPath) { + checklistPath = filepath.Join(filepath.Dir(path), checklistPath) + } + sum, err := fileSHA256(checklistPath) + if err != nil { + return nil, fmt.Sprintf( + "acceptances REJECTED: the referenced checklist %s could not be hashed (%v) - nothing was accepted", + f.ChecklistFile, err), nil + } + if sum != f.ChecklistSHA256 { + return nil, fmt.Sprintf( + "acceptances REJECTED: %s no longer matches the reviewed checklist (sha256 %s, file has %s) - re-review and re-accept; nothing was accepted", + f.ChecklistFile, f.ChecklistSHA256, sum), nil + } + } + return f.Acceptances, "", nil +} + +// loadMigrationReportFile reads and minimally validates a report.json from +// a migration run. The mode is validated by the checklist engine (a +// dry-run report is accepted as input but ignored as evidence, with a +// warning); here we only reject files that are not run reports at all. +func loadMigrationReportFile(path string) (accountinventory.MigrationReportInfo, error) { + var r accountinventory.MigrationReportInfo + b, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return r, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(b, &r); err != nil { + return r, fmt.Errorf("parse %s: %w", path, err) + } + if r.Mode == "" || r.ExitStatus == "" { + return r, fmt.Errorf("%s: not a migration run report (missing mode/exit_status)", path) + } + return r, nil +} diff --git a/cmd/cpanel-self-migration/inventory_checklist_cmd_test.go b/cmd/cpanel-self-migration/inventory_checklist_cmd_test.go new file mode 100644 index 00000000..bd6f4ca3 --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_checklist_cmd_test.go @@ -0,0 +1,538 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +// checklistFixtureFiles runs the REAL pipeline into a temp dir: two +// inventories -> diff -> policy, all written with the real writers. mutate +// tweaks the destination before diffing. Returns the four required paths. +func checklistFixtureFiles(t *testing.T, dir string, withMail bool, mutate func(dest *accountinventory.NormalizedInventory)) (src, dest, diff, policy string) { + t.Helper() + s := accountinventory.NewEmptyInventory("srcacct", "192.0.2.1", "source") + s.Domains = []accountinventory.DomainEntry{{Name: "main.example", Type: "main", DocumentRoot: "/home/srcacct/public_html"}} + s.Databases = []accountinventory.DatabaseEntry{{Name: "srcacct_db", Users: []string{"srcacct_u"}}} + if withMail { + s.Mailboxes = []accountinventory.MailboxEntry{{Email: "info@main.example", Domain: "main.example", User: "info"}} + } + s.FTP.Available = true + s.SSL.Available = true + s.PHP.Available = true + s.DNS.Available = true + s.Cron.Available = true + s.EmailRouting.Available = true + s.EmailRouting.Items = []accountinventory.EmailRoutingEntry{{ + Domain: "main.example", Routing: "local", Detected: "local", AlwaysAccept: true, + MXRecords: []accountinventory.MXRecordEntry{{Priority: 0, Exchange: "main.example"}}, + }} + s.DefaultAddresses.Available = true + s.DefaultAddresses.Items = []accountinventory.DefaultAddressEntry{{ + Domain: "main.example", DefaultAddress: `":fail: No Such User Here"`, + }} + s.EmailFilters.Available = true + s.Redirects.Available = true + + // Every slice is re-allocated so a mutate callback can never corrupt + // the source fixture through a shared backing array. + d := s + d.Account = accountinventory.AccountInfo{User: "destacct", Host: "192.0.2.2", CollectedAt: s.Account.CollectedAt, Side: "destination"} + d.Domains = append([]accountinventory.DomainEntry{}, s.Domains...) + d.Mailboxes = append([]accountinventory.MailboxEntry{}, s.Mailboxes...) + d.Databases = append([]accountinventory.DatabaseEntry{}, s.Databases...) + d.Forwarders = append([]accountinventory.NormForwarderEntry{}, s.Forwarders...) + d.Autoresponders = append([]accountinventory.NormAutoresponderEntry{}, s.Autoresponders...) + d.FTP.Items = append([]accountinventory.FTPEntry{}, s.FTP.Items...) + d.SSL.Items = append([]accountinventory.SSLEntry{}, s.SSL.Items...) + d.PHP.Items = append([]accountinventory.PHPEntry{}, s.PHP.Items...) + d.DNS.Zones = append([]accountinventory.DNSZoneResult{}, s.DNS.Zones...) + d.Cron.Jobs = append([]accountinventory.CronJobEntry{}, s.Cron.Jobs...) + d.EmailRouting.Items = append([]accountinventory.EmailRoutingEntry{}, s.EmailRouting.Items...) + d.DefaultAddresses.Items = append([]accountinventory.DefaultAddressEntry{}, s.DefaultAddresses.Items...) + d.EmailFilters.Items = append([]accountinventory.NormEmailFilterEntry{}, s.EmailFilters.Items...) + d.Redirects.Items = append([]accountinventory.RedirectEntry{}, s.Redirects.Items...) + if mutate != nil { + mutate(&d) + } + + src = filepath.Join(dir, "inventory_source.json") + dest = filepath.Join(dir, "inventory_destination.json") + if err := accountinventory.WriteInventoryJSON(src, s); err != nil { + t.Fatal(err) + } + if err := accountinventory.WriteInventoryJSON(dest, d); err != nil { + t.Fatal(err) + } + + dd := accountinventory.DiffInventories(s, d) + dd.SourceFile, dd.DestinationFile, dd.GeneratedAt = src, dest, "t" + diff = filepath.Join(dir, "inventory_diff.json") + if err := accountinventory.WriteDiffJSON(diff, dd); err != nil { + t.Fatal(err) + } + + pr := accountinventory.EvaluatePolicy(dd) + pr.InputDiff, pr.GeneratedAt = diff, "t" + policy = filepath.Join(dir, "policy_report.json") + if err := accountinventory.WritePolicyJSON(policy, pr); err != nil { + t.Fatal(err) + } + return src, dest, diff, policy +} + +// writeApplyReport writes a minimal but real-shaped report.json from a +// successful full apply run. +func writeApplyReport(t *testing.T, dir string) string { + t.Helper() + path := filepath.Join(dir, "report.json") + body := `{ + "run_id": "run-test", + "version": "test", + "mode": "apply", + "scope": {"mail": true, "files": true, "databases": true}, + "exit_status": "success" +}` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func readChecklistJSON(t *testing.T, path string) accountinventory.MigrationChecklist { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("checklist JSON not readable: %v", err) + } + var c accountinventory.MigrationChecklist + if err := json.Unmarshal(b, &c); err != nil { + t.Fatalf("checklist JSON not parsable: %v", err) + } + return c +} + +func TestInventoryChecklistCmdHappyPath(t *testing.T) { + dir := t.TempDir() + src, dest, diff, policy := checklistFixtureFiles(t, dir, true, nil) + rep := writeApplyReport(t, dir) + outJSON := filepath.Join(dir, "checklist.json") + outMD := filepath.Join(dir, "checklist.md") + + code := runInventoryChecklistCmd([]string{ + "--source", src, "--destination", dest, "--diff", diff, "--policy", policy, + "--migration-report", rep, "--output-json", outJSON, "--output-md", outMD, + }) + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + c := readChecklistJSON(t, outJSON) + if c.Mode != "migration-checklist" || c.FormatVersion != 1 { + t.Errorf("mode/version = %q/%d", c.Mode, c.FormatVersion) + } + if c.Account != "srcacct" { + t.Errorf("account = %q, want srcacct", c.Account) + } + // PR 7E: the mail areas are real sections now - with identical data + // and full apply evidence nothing gates. + if c.OverallStatus != accountinventory.OverallReadyToCutover { + t.Errorf("overall = %q, want %q", c.OverallStatus, accountinventory.OverallReadyToCutover) + } + if !c.Inputs.SourceInventory.Present || c.Inputs.SourceInventory.SHA256 == "" { + t.Error("source inventory input ref missing sha256") + } + if c.Inputs.DNSPlan.Present { + t.Error("dns plan marked present although it was not provided") + } + if c.ChainVerified { + t.Error("chain_verified must be false in PR 7A") + } + md, err := os.ReadFile(outMD) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(md), "# Migration Checklist - srcacct") { + t.Error("markdown missing the title") + } +} + +func TestInventoryChecklistCmdFailOnNotReadyGates(t *testing.T) { + dir := t.TempDir() + // No migration report: core areas have no evidence -> NOT_READY (mail + // absent so the overall is not MANUAL_ACTION_REQUIRED). + src, dest, diff, policy := checklistFixtureFiles(t, dir, false, nil) + outJSON := filepath.Join(dir, "checklist.json") + outMD := filepath.Join(dir, "checklist.md") + + code := runInventoryChecklistCmd([]string{ + "--source", src, "--destination", dest, "--diff", diff, "--policy", policy, + "--fail-on-not-ready", "--output-json", outJSON, "--output-md", outMD, + }) + // Literal 3 asserted on purpose: CLI contract (docs/COMMAND.md). + if code != 3 { + t.Fatalf("exit = %d, want 3", code) + } + // The gate must NOT suppress report generation. + c := readChecklistJSON(t, outJSON) + if c.OverallStatus != accountinventory.OverallNotReady { + t.Errorf("overall = %q, want %q", c.OverallStatus, accountinventory.OverallNotReady) + } + if _, err := os.Stat(outMD); err != nil { + t.Errorf("markdown not written before gating exit: %v", err) + } +} + +func TestInventoryChecklistCmdFailOnNotReadyPassesWhenReady(t *testing.T) { + dir := t.TempDir() + src, dest, diff, policy := checklistFixtureFiles(t, dir, false, nil) + rep := writeApplyReport(t, dir) + outJSON := filepath.Join(dir, "checklist.json") + + code := runInventoryChecklistCmd([]string{ + "--source", src, "--destination", dest, "--diff", diff, "--policy", policy, + "--migration-report", rep, "--fail-on-not-ready", + "--output-json", outJSON, "--output-md", filepath.Join(dir, "checklist.md"), + }) + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + c := readChecklistJSON(t, outJSON) + if c.OverallStatus != accountinventory.OverallReadyToCutover { + t.Fatalf("fixture produced overall %q, want %q (test would be vacuous)", + c.OverallStatus, accountinventory.OverallReadyToCutover) + } +} + +func TestInventoryChecklistCmdNotReadyWithoutGateExitsZero(t *testing.T) { + dir := t.TempDir() + src, dest, diff, policy := checklistFixtureFiles(t, dir, true, nil) + + code := runInventoryChecklistCmd([]string{ + "--source", src, "--destination", dest, "--diff", diff, "--policy", policy, + "--output-json", filepath.Join(dir, "c.json"), "--output-md", filepath.Join(dir, "c.md"), + }) + if code != 0 { + t.Errorf("exit = %d, want 0 (manual actions are findings, not process errors)", code) + } +} + +func TestInventoryChecklistCmdAcceptsDNSPlan(t *testing.T) { + dir := t.TempDir() + src, dest, diff, policy := checklistFixtureFiles(t, dir, false, nil) + + // A real plan produced by the real builder. + var s, d accountinventory.NormalizedInventory + for path, dst := range map[string]*accountinventory.NormalizedInventory{src: &s, dest: &d} { + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, dst); err != nil { + t.Fatal(err) + } + } + plan, err := accountinventory.BuildDNSPlan(s, d, nil, map[string]string{}) + if err != nil { + t.Fatal(err) + } + plan.GeneratedAt = "t" + planPath := filepath.Join(dir, "dns_import_plan.json") + if err := accountinventory.WriteDNSPlanJSON(planPath, plan); err != nil { + t.Fatal(err) + } + + outJSON := filepath.Join(dir, "c.json") + code := runInventoryChecklistCmd([]string{ + "--source", src, "--destination", dest, "--diff", diff, "--policy", policy, + "--dns-plan", planPath, + "--output-json", outJSON, "--output-md", filepath.Join(dir, "c.md"), + }) + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + if c := readChecklistJSON(t, outJSON); !c.Inputs.DNSPlan.Present || c.Inputs.DNSPlan.SHA256 == "" { + t.Error("dns plan input ref not recorded") + } +} + +func TestInventoryChecklistCmdInputErrors(t *testing.T) { + dir := t.TempDir() + src, dest, diff, policy := checklistFixtureFiles(t, dir, false, nil) + bad := filepath.Join(dir, "bad.json") + if err := os.WriteFile(bad, []byte("{{{"), 0o600); err != nil { + t.Fatal(err) + } + notAPlan := filepath.Join(dir, "notaplan.json") + if err := os.WriteFile(notAPlan, []byte(`{"mode":"something-else"}`), 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + args []string + want int + }{ + {"missing required flags", []string{}, 1}, + {"missing source file", []string{"--source", "/nonexistent.json", "--destination", dest, "--diff", diff, "--policy", policy}, 1}, + {"invalid diff JSON", []string{"--source", src, "--destination", dest, "--diff", bad, "--policy", policy}, 1}, + {"wrong-mode policy", []string{"--source", src, "--destination", dest, "--diff", diff, "--policy", notAPlan}, 1}, + {"wrong-mode dns plan", []string{"--source", src, "--destination", dest, "--diff", diff, "--policy", policy, "--dns-plan", notAPlan}, 1}, + {"non-report migration report", []string{"--source", src, "--destination", dest, "--diff", diff, "--policy", policy, "--migration-report", notAPlan}, 1}, + {"bad flag", []string{"--no-such-flag"}, 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := append(tt.args, "--output-json", filepath.Join(dir, "o.json"), "--output-md", filepath.Join(dir, "o.md")) + if tt.want == 2 { + args = tt.args + } + if code := runInventoryChecklistCmd(args); code != tt.want { + t.Errorf("exit = %d, want %d", code, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Operator acceptances (PR 7D) +// --------------------------------------------------------------------------- + +// writeAcceptances writes an acceptances.json accepting the given keys, +// bound to the given checklist file (hash computed unless overridden). +func writeAcceptances(t *testing.T, dir, checklistPath, sha string, keys []string) string { + t.Helper() + if sha == "" { + var err error + sha, err = fileSHA256(checklistPath) + if err != nil { + t.Fatal(err) + } + } + f := accountinventory.AcceptanceFile{ + Mode: accountinventory.AcceptanceFileMode, FormatVersion: 1, + ChecklistFile: checklistPath, ChecklistSHA256: sha, + } + for _, k := range keys { + f.Acceptances = append(f.Acceptances, accountinventory.OperatorAcceptance{ + ActionKey: k, Reason: "reviewed with the customer", + AcceptedBy: "reviewer", AcceptedAt: "2026-07-02T10:00:00Z", + }) + } + b, err := json.MarshalIndent(f, "", " ") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "acceptances.json") + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// TestInventoryChecklistCmdAcceptancesFlow is the full operator loop: +// generate the checklist, accept every blocking action by its stable key, +// regenerate with --acceptances -> the gate clears, the acceptance is +// visible in the output, and --fail-on-not-ready passes. +func TestInventoryChecklistCmdAcceptancesFlow(t *testing.T) { + dir := t.TempDir() + src, dest, diff, policy := checklistFixtureFiles(t, dir, true, func(d *accountinventory.NormalizedInventory) { + d.EmailRouting.Items[0].Routing = "remote" // blocking, acceptable CONFIRM_EMAIL_ROUTING + }) + rep := writeApplyReport(t, dir) + outJSON := filepath.Join(dir, "checklist.json") + outMD := filepath.Join(dir, "checklist.md") + baseArgs := []string{ + "--source", src, "--destination", dest, "--diff", diff, "--policy", policy, + "--migration-report", rep, "--output-json", outJSON, "--output-md", outMD, + } + + if code := runInventoryChecklistCmd(baseArgs); code != 0 { + t.Fatalf("first run exit = %d, want 0", code) + } + base := readChecklistJSON(t, outJSON) + if base.OverallStatus != accountinventory.OverallManualActionRequired { + t.Fatalf("baseline overall = %q, want MANUAL_ACTION_REQUIRED", base.OverallStatus) + } + var keys []string + for _, a := range base.ManualActions { + if a.BlockingCutover && a.Acceptable { + keys = append(keys, a.Key) + } + } + if len(keys) == 0 { + t.Fatal("no acceptable blocking actions in the baseline - scenario invalid") + } + accPath := writeAcceptances(t, dir, outJSON, "", keys) + + code := runInventoryChecklistCmd(append(append([]string{}, baseArgs...), + "--acceptances", accPath, "--fail-on-not-ready")) + if code != 0 { + t.Fatalf("second run exit = %d, want 0 (gate cleared by acceptances)", code) + } + c := readChecklistJSON(t, outJSON) + if c.OverallStatus != accountinventory.OverallReadyWithManualNotes { + t.Fatalf("overall = %q, want READY_WITH_MANUAL_NOTES", c.OverallStatus) + } + if c.Summary.Accepted != len(keys) { + t.Errorf("summary.accepted = %d, want %d", c.Summary.Accepted, len(keys)) + } + if !c.Inputs.Acceptances.Present || c.Inputs.Acceptances.SHA256 == "" { + t.Error("acceptances input ref missing from the audit trail") + } + md, err := os.ReadFile(outMD) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(md), "accepted (reviewer)") { + t.Error("markdown missing the accepted marker") + } +} + +// TestInventoryChecklistCmdAcceptancesHashMismatchRejected: when the +// acceptance file names its reviewed checklist and the hash does NOT match, +// the WHOLE acceptance file is rejected (warning) - nothing is accepted. +func TestInventoryChecklistCmdAcceptancesHashMismatchRejected(t *testing.T) { + dir := t.TempDir() + src, dest, diff, policy := checklistFixtureFiles(t, dir, true, func(d *accountinventory.NormalizedInventory) { + d.EmailRouting.Items[0].Routing = "remote" // blocking, acceptable CONFIRM_EMAIL_ROUTING + }) + rep := writeApplyReport(t, dir) + outJSON := filepath.Join(dir, "checklist.json") + outMD := filepath.Join(dir, "checklist.md") + baseArgs := []string{ + "--source", src, "--destination", dest, "--diff", diff, "--policy", policy, + "--migration-report", rep, "--output-json", outJSON, "--output-md", outMD, + } + if code := runInventoryChecklistCmd(baseArgs); code != 0 { + t.Fatalf("first run exit = %d, want 0", code) + } + base := readChecklistJSON(t, outJSON) + var keys []string + for _, a := range base.ManualActions { + if a.BlockingCutover && a.Acceptable { + keys = append(keys, a.Key) + } + } + accPath := writeAcceptances(t, dir, outJSON, + "0000000000000000000000000000000000000000000000000000000000000000", keys) + + code := runInventoryChecklistCmd(append(append([]string{}, baseArgs...), "--acceptances", accPath)) + if code != 0 { + t.Fatalf("exit = %d, want 0 (checklist still generated, acceptances rejected)", code) + } + c := readChecklistJSON(t, outJSON) + if c.Summary.Accepted != 0 { + t.Errorf("summary.accepted = %d, want 0: mismatched hash must reject the whole file", c.Summary.Accepted) + } + if c.OverallStatus != accountinventory.OverallManualActionRequired { + t.Errorf("overall = %q, want MANUAL_ACTION_REQUIRED", c.OverallStatus) + } + found := false + for _, w := range c.Warnings { + if strings.Contains(w, "acceptances") && strings.Contains(w, "sha256") { + found = true + } + } + if !found { + t.Errorf("warnings = %v, want the hash-mismatch rejection warning", c.Warnings) + } + // The forensic trail survives the rejection: the file WAS submitted. + if !c.Inputs.Acceptances.Present || c.Inputs.Acceptances.SHA256 == "" { + t.Error("rejected acceptance file must still appear in the inputs audit trail") + } +} + +// TestInventoryChecklistCmdAcceptancesInvalidFile: wrong mode or missing +// required fields are hard input errors (exit 1), same as the other loaders. +func TestInventoryChecklistCmdAcceptancesInvalidFile(t *testing.T) { + dir := t.TempDir() + src, dest, diff, policy := checklistFixtureFiles(t, dir, true, nil) + baseArgs := []string{ + "--source", src, "--destination", dest, "--diff", diff, "--policy", policy, + "--output-json", filepath.Join(dir, "c.json"), "--output-md", filepath.Join(dir, "c.md"), + } + cases := []struct { + name, body string + }{ + {"wrong mode", `{"mode":"nope","format_version":1,"checklist_sha256":"x","acceptances":[]}`}, + {"missing key", `{"mode":"operator-acceptances","format_version":1,"checklist_sha256":"x","acceptances":[{"reason":"r","accepted_by":"a","accepted_at":"t"}]}`}, + {"missing reason", `{"mode":"operator-acceptances","format_version":1,"checklist_sha256":"x","acceptances":[{"action_key":"AK-000000000000","accepted_by":"a","accepted_at":"t"}]}`}, + {"missing author", `{"mode":"operator-acceptances","format_version":1,"checklist_sha256":"x","acceptances":[{"action_key":"AK-000000000000","reason":"r","accepted_at":"t"}]}`}, + {"missing checklist hash", `{"mode":"operator-acceptances","format_version":1,"acceptances":[]}`}, + {"unsupported format_version", `{"mode":"operator-acceptances","format_version":2,"checklist_sha256":"x","acceptances":[]}`}, + {"not json", `{`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := filepath.Join(dir, "bad_acceptances.json") + if err := os.WriteFile(p, []byte(tc.body), 0o600); err != nil { + t.Fatal(err) + } + if code := runInventoryChecklistCmd(append(append([]string{}, baseArgs...), "--acceptances", p)); code != 1 { + t.Errorf("exit = %d, want 1", code) + } + }) + } +} + +// TestInventoryChecklistCmdAcceptancesRelativeChecklistFile: a relative +// checklist_file resolves against the acceptance file's own directory, so +// the reviewed checklist + acceptances pair can be moved together. +func TestInventoryChecklistCmdAcceptancesRelativeChecklistFile(t *testing.T) { + dir := t.TempDir() + src, dest, diff, policy := checklistFixtureFiles(t, dir, true, nil) + rep := writeApplyReport(t, dir) + outJSON := filepath.Join(dir, "checklist.json") + outMD := filepath.Join(dir, "checklist.md") + baseArgs := []string{ + "--source", src, "--destination", dest, "--diff", diff, "--policy", policy, + "--migration-report", rep, "--output-json", outJSON, "--output-md", outMD, + } + if code := runInventoryChecklistCmd(baseArgs); code != 0 { + t.Fatalf("first run exit = %d, want 0", code) + } + base := readChecklistJSON(t, outJSON) + var keys []string + for _, a := range base.ManualActions { + if a.BlockingCutover && a.Acceptable { + keys = append(keys, a.Key) + } + } + sha, err := fileSHA256(outJSON) + if err != nil { + t.Fatal(err) + } + f := accountinventory.AcceptanceFile{ + Mode: accountinventory.AcceptanceFileMode, FormatVersion: 1, + ChecklistFile: "checklist.json", // relative to the acceptance file's dir + ChecklistSHA256: sha, + } + for _, k := range keys { + f.Acceptances = append(f.Acceptances, accountinventory.OperatorAcceptance{ + ActionKey: k, Reason: "reviewed", AcceptedBy: "reviewer", AcceptedAt: "2026-07-02T10:00:00Z", + }) + } + b, err := json.MarshalIndent(f, "", " ") + if err != nil { + t.Fatal(err) + } + accPath := filepath.Join(dir, "acceptances.json") + if err := os.WriteFile(accPath, b, 0o600); err != nil { + t.Fatal(err) + } + + if code := runInventoryChecklistCmd(append(append([]string{}, baseArgs...), "--acceptances", accPath)); code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + c := readChecklistJSON(t, outJSON) + if c.Summary.Accepted != len(keys) { + t.Errorf("summary.accepted = %d, want %d: the relative checklist_file must resolve and verify", c.Summary.Accepted, len(keys)) + } +} diff --git a/cmd/cpanel-self-migration/inventory_contract_test.go b/cmd/cpanel-self-migration/inventory_contract_test.go new file mode 100644 index 00000000..3ae24d64 --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_contract_test.go @@ -0,0 +1,460 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +// Inventory contract audit: runs the REAL --account-inventory flow end to +// end - real sshx transport against an in-process SSH server, stub +// uapi/cpapi2/crontab binaries answering in the live-server response shapes +// validated against cPanel 110.0 (build 131) - then audits every produced +// artifact against the inventory contract the future policy engine will +// rely on: +// +// - no null arrays anywhere in the JSON; +// - unavailable sections say available:false + method:"unavailable"; +// - secrets planted in the fake crontab never reach any artifact, +// not even as a raw-command sha256 (oracle check); +// - events.jsonl carries run_started/run_completed with a coherent +// run_id; report.json aggregates warnings. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/config" + "github.com/tis24dev/cPanel_self-migration/internal/events" + "github.com/tis24dev/cPanel_self-migration/internal/sshtest" +) + +// plantedSecret is embedded in the stub crontab: it must never surface in +// any artifact, in any form. +const plantedSecret = "SmokeSecretXYZ123" + +// plantedRawCommand is the raw cron command containing the secret; its +// sha256 must not appear either (a raw-command hash next to the redacted +// command would be an offline brute-force oracle). +const plantedRawCommand = "/usr/local/bin/backup.sh --password=" + plantedSecret + " | gzip > /tmp/b.gz" + +const stubCrontab = `MAILTO=ops@example.test +# nightly backup +0 3 * * * ` + plantedRawCommand + ` +@daily /usr/bin/php /home/u/cron.php +#30 2 * * 0 /bin/weekly.sh +` + +// setupInventoryStubs creates stub uapi/cpapi2/crontab executables that +// serve the shared fixtures, prepends them to PATH and points HOME at a +// temp dir so the TOFU known_hosts never touches the real one. +func setupInventoryStubs(t *testing.T, dnsUAPIFails, noCrontab bool) { + t.Helper() + tmp := t.TempDir() + stubDir := filepath.Join(tmp, "bin") + fixDir := filepath.Join(tmp, "fixtures") + for _, d := range []string{stubDir, fixDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + + // Reuse the repo fixtures (live-server shapes) plus inline extras. + for _, name := range []string{ + "domaininfo_list.json", "domaininfo_domains_data.json", + "email_list_pops.json", "email_forwarders.json", "email_autoresponders.json", + "ftp_list.json", "ssl_list_certs.json", "php_vhost_versions.json", + "dns_parse_zone.json", "dns_fetchzone_records.json", + "email_list_mxs_realserver.json", "email_default_address_realserver.json", + "email_list_filters.json", "mime_redirects_realserver.json", + } { + b, err := os.ReadFile(filepath.Join("..", "..", "internal", "testdata", name)) + if err != nil { + t.Fatalf("fixture %s: %v", name, err) + } + if err := os.WriteFile(filepath.Join(fixDir, name), b, 0o600); err != nil { + t.Fatal(err) + } + } + writeFix := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(fixDir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + writeFix("mysql_list_databases.json", + `{"result":{"status":1,"data":[{"database":"u_wp","disk_usage":2048,"users":["u_admin"]}]}}`) + writeFix("mysql_list_users.json", + `{"result":{"status":1,"data":[{"user":"u_admin","shortuser":"admin","databases":["u_wp"]}]}}`) + writeFix("dns_parse_zone_fail.json", + `{"result":{"status":0,"data":null,"errors":["Failed to load module DNS"]}}`) + writeFix("crontab_sample.txt", stubCrontab) + + writeStub := func(name, body string) { + t.Helper() + path := filepath.Join(stubDir, name) + if err := os.WriteFile(path, []byte("#!/bin/bash\n"+body), 0o755); err != nil { // #nosec G306 -- stub must be executable + t.Fatal(err) + } + } + + dnsFixture := "dns_parse_zone.json" + if dnsUAPIFails { + dnsFixture = "dns_parse_zone_fail.json" + } + writeStub("uapi", `shift # drop --output=json +case "$1 $2" in + "DomainInfo list_domains") cat "$CPSM_TEST_FIXDIR/domaininfo_list.json" ;; + "DomainInfo domains_data") cat "$CPSM_TEST_FIXDIR/domaininfo_domains_data.json" ;; + "Email list_pops_with_disk") cat "$CPSM_TEST_FIXDIR/email_list_pops.json" ;; + "Email list_forwarders") cat "$CPSM_TEST_FIXDIR/email_forwarders.json" ;; + "Email list_auto_responders") cat "$CPSM_TEST_FIXDIR/email_autoresponders.json" ;; + "Mysql list_databases") cat "$CPSM_TEST_FIXDIR/mysql_list_databases.json" ;; + "Mysql list_users") cat "$CPSM_TEST_FIXDIR/mysql_list_users.json" ;; + "Ftp list_ftp_with_disk") cat "$CPSM_TEST_FIXDIR/ftp_list.json" ;; + "SSL list_certs") cat "$CPSM_TEST_FIXDIR/ssl_list_certs.json" ;; + "LangPHP php_get_vhost_versions") cat "$CPSM_TEST_FIXDIR/php_vhost_versions.json" ;; + "DNS parse_zone") cat "$CPSM_TEST_FIXDIR/`+dnsFixture+`" ;; + "Email list_mxs") cat "$CPSM_TEST_FIXDIR/email_list_mxs_realserver.json" ;; + "Email list_default_address") cat "$CPSM_TEST_FIXDIR/email_default_address_realserver.json" ;; + "Email list_filters") cat "$CPSM_TEST_FIXDIR/email_list_filters.json" ;; + "Mime list_redirects") cat "$CPSM_TEST_FIXDIR/mime_redirects_realserver.json" ;; + *) echo '{"result":{"status":0,"errors":["stub: unknown uapi call"]}}' ;; +esac`) + writeStub("cpapi2", `shift # drop --output=json +case "$1 $2" in + "ZoneEdit fetchzone_records") cat "$CPSM_TEST_FIXDIR/dns_fetchzone_records.json" ;; + *) echo '{"cpanelresult":{"data":[],"event":{"result":0},"error":"stub: unknown cpapi2 call"}}' ;; +esac`) + if noCrontab { + writeStub("crontab", `if [ "$1" = "-l" ]; then echo "no crontab for u" >&2; exit 1; fi; exit 1`) + } else { + writeStub("crontab", `if [ "$1" = "-l" ]; then cat "$CPSM_TEST_FIXDIR/crontab_sample.txt"; exit 0; fi; exit 1`) + } + + t.Setenv("CPSM_TEST_FIXDIR", fixDir) + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HOME", tmp) // keep the TOFU known_hosts out of the real ~/.ssh +} + +// runInventoryFlow drives the real runAccountInventory against the stub +// SSH server and returns the artifact directory. +func runInventoryFlow(t *testing.T) string { + t.Helper() + home := t.TempDir() + addr := sshtest.NewExecServer(t, home) + hostIP, portStr, err := net.SplitHostPort(addr) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatal(err) + } + + cfg := config.Config{Src: config.HostConfig{ + IP: hostIP, Port: port, SSHUser: "u", SSHPass: "p", Timeout: 10 * time.Second, + }} + + outDir := t.TempDir() + ew, err := events.NewWriter(filepath.Join(outDir, "events.jsonl")) + if err != nil { + t.Fatal(err) + } + em := events.Emitter{Emit: func(e events.Event) { + if werr := ew.Write(e); werr != nil { + t.Errorf("events write: %v", werr) + } + }} + + runID := "run-20260701-000000" + if err := runAccountInventory(t.Context(), cfg, outDir, runID, em, true); err != nil { + t.Fatalf("runAccountInventory: %v", err) + } + if err := ew.Close(); err != nil { + t.Fatal(err) + } + return outDir +} + +func TestAccountInventoryContract(t *testing.T) { + sshtest.RequireTools(t, "bash", "cat") + setupInventoryStubs(t, false, false) + outDir := runInventoryFlow(t) + + // 1. Artifacts present. + for _, f := range []string{"inventory_source.json", "inventory_report.md", "report.json", "events.jsonl"} { + if _, err := os.Stat(filepath.Join(outDir, f)); err != nil { + t.Fatalf("artifact %s missing: %v", f, err) + } + } + + rawJSON, err := os.ReadFile(filepath.Join(outDir, "inventory_source.json")) + if err != nil { + t.Fatal(err) + } + var inv map[string]any + if err := json.Unmarshal(rawJSON, &inv); err != nil { + t.Fatalf("inventory_source.json invalid: %v", err) + } + + // 2. All sections present. + for _, section := range []string{ + "account", "domains", "mailboxes", "databases", + "forwarders", "autoresponders", "ftp", "ssl", "php", "dns", "cron", + "email_routing", "default_address", "email_filters", "redirects", + } { + if _, ok := inv[section]; !ok { + t.Errorf("section %q missing from inventory JSON", section) + } + } + + // 3. Contract: no null anywhere in the tree. + checkNoNulls(t, inv, "$") + + // Section semantics on the happy path. + checkSectionAvailable(t, inv, "ftp", "uapi") + checkSectionAvailable(t, inv, "ssl", "uapi") + checkSectionAvailable(t, inv, "php", "uapi") + checkSectionAvailable(t, inv, "dns", "uapi") + checkSectionAvailable(t, inv, "cron", "ssh_crontab_l") + checkSectionAvailable(t, inv, "email_routing", "uapi") + checkSectionAvailable(t, inv, "default_address", "uapi") + checkSectionAvailable(t, inv, "email_filters", "uapi") + checkSectionAvailable(t, inv, "redirects", "uapi") + + cron := inv["cron"].(map[string]any) + jobs := cron["jobs"].([]any) + if len(jobs) != 3 { + t.Errorf("cron jobs = %d, want 3 (2 enabled + 1 disabled)", len(jobs)) + } + dns := inv["dns"].(map[string]any) + zones := dns["zones"].([]any) + if len(zones) == 0 { + t.Error("dns zones empty on happy path") + } + + // 4. Security: the planted secret must not appear in ANY read-only + // artifact. The read-only inventory JSON no longer carries the raw + // command/env (command_clear/raw_line/value_clear are in-memory only, + // tagged json:"-"): the planted secret is absent from every file. The + // sha256 oracle check applies to the redacted-hash fields only + // (command_sha256 is over the redacted text). + rawHash := sha256.Sum256([]byte(plantedRawCommand)) + oracleHex := hex.EncodeToString(rawHash[:]) + for _, f := range []string{"inventory_source.json", "inventory_report.md", "report.json", "events.jsonl"} { + b, err := os.ReadFile(filepath.Join(outDir, f)) + if err != nil { + t.Fatal(err) + } + s := string(b) + if strings.Contains(s, plantedSecret) { + t.Errorf("%s leaks the planted secret", f) + } + if strings.Contains(s, oracleHex) { + t.Errorf("%s contains the sha256 of the RAW cron command (brute-force oracle)", f) + } + } + // The read-only inventory JSON must NOT carry the raw command/env: the + // clear/raw fields are in-memory only (json:"-"). Verify the raw json + // keys are absent and the redacted fields do not leak the secret. + invBytes, err := os.ReadFile(filepath.Join(outDir, "inventory_source.json")) + if err != nil { + t.Fatal(err) + } + invStr := string(invBytes) + if strings.Contains(invStr, oracleHex) { + t.Error("inventory_source.json contains the sha256 of the RAW command (oracle leak)") + } + for _, k := range []string{`"command_clear"`, `"raw_line"`, `"value_clear"`} { + if strings.Contains(invStr, k) { + t.Errorf("inventory_source.json must not serialize raw field %s (json:\"-\")", k) + } + } + // Parse and verify redacted fields are clean (C1 fix). + var invJSON struct { + Cron struct { + Jobs []struct { + CommandRedacted string `json:"command_redacted"` + } `json:"jobs"` + Environment []struct { + ValueRedacted string `json:"value_redacted"` + } `json:"environment"` + } `json:"cron"` + } + if err := json.Unmarshal(invBytes, &invJSON); err != nil { + t.Fatalf("parse inventory JSON: %v", err) + } + for i, j := range invJSON.Cron.Jobs { + if strings.Contains(j.CommandRedacted, plantedSecret) { + t.Errorf("cron.jobs[%d].command_redacted leaks the planted secret", i) + } + } + for i, e := range invJSON.Cron.Environment { + if strings.Contains(e.ValueRedacted, plantedSecret) { + t.Errorf("cron.environment[%d].value_redacted leaks the planted secret", i) + } + } + + // The redacted cron command is present and its pipe is table-escaped. + md, err := os.ReadFile(filepath.Join(outDir, "inventory_report.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(md), "[REDACTED]") { + t.Error("report.md should show a redacted cron command") + } + if !strings.Contains(string(md), `\|`) { + t.Error("piped cron command must be escaped in the markdown table") + } + + // 5. Events and report.json coherence. + checkEvents(t, outDir, "run-20260701-000000") + checkRunReport(t, outDir, "run-20260701-000000") + + // Section counts, logged as living documentation of the contract run. + for _, section := range []string{"domains", "mailboxes", "databases", "forwarders", "autoresponders"} { + if items, ok := inv[section].([]any); ok { + t.Logf("%-15s %d item(s)", section, len(items)) + } + } + for _, section := range []string{"ftp", "ssl", "php"} { + if sec, ok := inv[section].(map[string]any); ok { + t.Logf("%-15s %d item(s)", section, len(sec["items"].([]any))) + } + } + t.Logf("%-15s %d zone(s)", "dns", len(zones)) + t.Logf("%-15s %d job(s), %d env, %v comments, %v disabled", "cron", + len(jobs), len(cron["environment"].([]any)), cron["comments_count"], cron["disabled_jobs_count"]) +} + +func TestAccountInventoryContractFallbacks(t *testing.T) { + sshtest.RequireTools(t, "bash", "cat") + setupInventoryStubs(t, true, true) // DNS UAPI fails; no crontab installed + outDir := runInventoryFlow(t) + + rawJSON, err := os.ReadFile(filepath.Join(outDir, "inventory_source.json")) + if err != nil { + t.Fatal(err) + } + var inv map[string]any + if err := json.Unmarshal(rawJSON, &inv); err != nil { + t.Fatal(err) + } + checkNoNulls(t, inv, "$") + + // DNS fell back to API2 end to end, through the real binary flow. + dns := inv["dns"].(map[string]any) + if dns["method"] != "api2" { + t.Errorf("dns method = %v, want api2 (UAPI parse_zone failed)", dns["method"]) + } + if dns["source_function"] != "ZoneEdit::fetchzone_records" { + t.Errorf("dns source_function = %v", dns["source_function"]) + } + + // Empty crontab is available with zero jobs and a light warning - + // NOT an unavailable section, NOT a bare empty list. + cron := inv["cron"].(map[string]any) + if cron["available"] != true { + t.Errorf("empty crontab must stay available, got %v", cron["available"]) + } + if jobs := cron["jobs"].([]any); len(jobs) != 0 { + t.Errorf("jobs = %d, want 0", len(jobs)) + } + if warns := cron["warnings"].([]any); len(warns) == 0 { + t.Error("empty crontab must carry a warning (not silently empty)") + } +} + +// checkNoNulls walks the decoded JSON tree and fails on any null value: +// the contract requires empty arrays/objects, never null. +func checkNoNulls(t *testing.T, v any, path string) { + t.Helper() + switch val := v.(type) { + case nil: + t.Errorf("null value at %s (contract: empty, never null)", path) + case map[string]any: + for k, child := range val { + checkNoNulls(t, child, path+"."+k) + } + case []any: + for i, child := range val { + checkNoNulls(t, child, fmt.Sprintf("%s[%d]", path, i)) + } + } +} + +func checkSectionAvailable(t *testing.T, inv map[string]any, section, wantMethod string) { + t.Helper() + sec, ok := inv[section].(map[string]any) + if !ok { + t.Errorf("section %s is not an object", section) + return + } + if sec["available"] != true { + t.Errorf("%s.available = %v, want true", section, sec["available"]) + } + if sec["method"] != wantMethod { + t.Errorf("%s.method = %v, want %q", section, sec["method"], wantMethod) + } +} + +func checkEvents(t *testing.T, outDir, wantRunID string) { + t.Helper() + b, err := os.ReadFile(filepath.Join(outDir, "events.jsonl")) + if err != nil { + t.Fatal(err) + } + var started, completed bool + for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { + var ev map[string]any + if err := json.Unmarshal([]byte(line), &ev); err != nil { + t.Fatalf("events.jsonl line invalid: %v", err) + } + if ev["run_id"] != wantRunID { + t.Errorf("event run_id = %v, want %s", ev["run_id"], wantRunID) + } + if ev["ts"] == nil { + t.Error("event missing timestamp") + } + switch ev["event"] { + case "run_started": + started = true + case "run_completed": + completed = true + } + } + if !started || !completed { + t.Errorf("events: started=%v completed=%v, want both", started, completed) + } +} + +func checkRunReport(t *testing.T, outDir, wantRunID string) { + t.Helper() + b, err := os.ReadFile(filepath.Join(outDir, "report.json")) + if err != nil { + t.Fatal(err) + } + var rpt map[string]any + if err := json.Unmarshal(b, &rpt); err != nil { + t.Fatalf("report.json invalid: %v", err) + } + if rpt["run_id"] != wantRunID { + t.Errorf("report run_id = %v, want %s", rpt["run_id"], wantRunID) + } + if rpt["mode"] != "account-inventory" { + t.Errorf("report mode = %v", rpt["mode"]) + } + if rpt["warnings"] == nil { + t.Error("report.json warnings must be an array, not null") + } + if rpt["started_at"] == nil || rpt["finished_at"] == nil { + t.Error("report.json missing timestamps") + } +} diff --git a/cmd/cpanel-self-migration/inventory_cronplan_cmd.go b/cmd/cpanel-self-migration/inventory_cronplan_cmd.go new file mode 100644 index 00000000..ab58be28 --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_cronplan_cmd.go @@ -0,0 +1,99 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +// deriveMDPath derives the Markdown report path from the JSON output path. +// Relocated here (the excluded email apply command owns the original) so the +// inventory cron-plan command stays self-contained without the write layer. +func deriveMDPath(jsonPath string) string { + if strings.HasSuffix(jsonPath, ".json") { + return strings.TrimSuffix(jsonPath, ".json") + ".md" + } + return jsonPath + ".md" +} + +// runInventoryCronPlanCmd implements `cpanel-self-migration inventory +// cron-plan`: a fully offline builder of the cron apply plan (PR 2A). +// It never connects to any server and never writes anything but the two +// report files. Exit codes: 0 = plan generated, 1 = invalid input or +// write failure, 2 = flags. +func runInventoryCronPlanCmd(args []string) int { + fs := flag.NewFlagSet("inventory cron-plan", flag.ContinueOnError) + source := fs.String("source", "", "path to the source inventory JSON (required)") + destination := fs.String("destination", "", "path to the destination inventory JSON (required)") + outJSON := fs.String("output-json", "cron_apply_plan.json", "path for the machine-readable plan") + outMD := fs.String("output-md", "", "path for the human-readable plan (default: derived from --output-json)") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration inventory cron-plan --source SRC.json --destination DEST.json [--output-json PATH] [--output-md PATH]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if *source == "" || *destination == "" { + fmt.Fprintln(os.Stderr, "error: --source and --destination are required") + fs.Usage() + return 1 + } + + srcInv, err := loadInventoryFile(*source) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + destInv, err := loadInventoryFile(*destination) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + plan := accountinventory.BuildCronPlan(srcInv, destInv) + plan.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + plan.SourceFile, plan.DestinationFile = *source, *destination + + if plan.SourceSHA256, err = fileSHA256(*source); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if plan.DestinationSHA256, err = fileSHA256(*destination); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + // PlanTimeDestCrontab stays empty: the plan is fully offline and + // cannot read the live destination crontab. The apply command reads + // the crontab at apply time and records the hash into the backup for + // the rollback guard. Future runs can supply --dest-crontab-hash + // to lock the plan to a known state. + + mdPath := *outMD + if mdPath == "" { + mdPath = deriveMDPath(*outJSON) + } + + if err := accountinventory.WriteCronPlanJSON(*outJSON, plan); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := accountinventory.WriteCronPlanMarkdown(mdPath, plan); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + fmt.Printf("cron plan: %d op(s) - %d create, %d skip, %d manual, %d informational\n", + len(plan.Ops), plan.Summary.Create, plan.Summary.Skip, plan.Summary.Manual, + plan.Summary.Informational) + fmt.Fprintf(os.Stderr, "wrote %s\nwrote %s\n", *outJSON, mdPath) + return 0 +} diff --git a/cmd/cpanel-self-migration/inventory_cronplan_cmd_test.go b/cmd/cpanel-self-migration/inventory_cronplan_cmd_test.go new file mode 100644 index 00000000..33e2410a --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_cronplan_cmd_test.go @@ -0,0 +1,168 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +func writeCronInventory(t *testing.T, dir, name, side, user string, + jobs []accountinventory.CronJobEntry, + envs []accountinventory.CronEnvEntry, +) string { + t.Helper() + inv := accountinventory.NewEmptyInventory(user, "192.0.2.1", side) + inv.Cron.Available = true + inv.Cron.Method = "ssh_crontab_l" + inv.Cron.Jobs = jobs + inv.Cron.Environment = envs + b, err := json.MarshalIndent(inv, "", " ") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestInventoryCronPlanCmdFlagAndInputErrors(t *testing.T) { + if code := runInventoryCronPlanCmd([]string{"--bogus"}); code != 2 { + t.Errorf("unknown flag: code = %d, want 2", code) + } + if code := runInventoryCronPlanCmd([]string{}); code != 1 { + t.Errorf("missing required flags: code = %d, want 1", code) + } + dir := t.TempDir() + src := writeCronInventory(t, dir, "src.json", "source", "acct", nil, nil) + if code := runInventoryCronPlanCmd([]string{"--source", src, "--destination", filepath.Join(dir, "nope.json")}); code != 1 { + t.Errorf("nonexistent destination: code = %d, want 1", code) + } +} + +func TestInventoryCronPlanCmdNotAnInventory(t *testing.T) { + dir := t.TempDir() + bogus := filepath.Join(dir, "x.json") + if err := os.WriteFile(bogus, []byte(`{"mode":"other"}`), 0o600); err != nil { + t.Fatal(err) + } + code := runInventoryCronPlanCmd([]string{"--source", bogus, "--destination", bogus}) + if code != 1 { + t.Errorf("exit = %d, want 1", code) + } +} + +func TestInventoryCronPlanCmdEndToEnd(t *testing.T) { + dir := t.TempDir() + srcJobs := []accountinventory.CronJobEntry{ + { + Type: "schedule", Minute: "0", Hour: "3", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/true", + CommandSHA256: "sha256:abc", + Enabled: true, CommandCollected: true, + }, + } + srcEnvs := []accountinventory.CronEnvEntry{ + {Name: "MAILTO", ValueRedacted: "test@example.com", ValueCollected: true}, + } + src := writeCronInventory(t, dir, "src.json", "source", "acct", srcJobs, srcEnvs) + dest := writeCronInventory(t, dir, "dest.json", "destination", "acct", nil, nil) + + outJSON := filepath.Join(dir, "cron_apply_plan.json") + outMD := filepath.Join(dir, "cron_apply_plan.md") + + code := runInventoryCronPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", outJSON, "--output-md", outMD, + }) + if code != 0 { + t.Fatalf("code = %d, want 0", code) + } + + b, err := os.ReadFile(outJSON) + if err != nil { + t.Fatal(err) + } + var plan accountinventory.CronApplyPlan + if err := json.Unmarshal(b, &plan); err != nil { + t.Fatal(err) + } + if plan.Mode != "cron-apply-plan" || plan.FormatVersion != 1 { + t.Errorf("mode/version = %q/%d", plan.Mode, plan.FormatVersion) + } + if plan.Summary.Create != 2 { // 1 job + 1 env + t.Errorf("summary = %+v, want 2 create", plan.Summary) + } + // The embedded hashes must match the raw input files. + srcSHA, err := fileSHA256(src) + if err != nil { + t.Fatal(err) + } + if plan.SourceSHA256 != srcSHA { + t.Errorf("source_sha256 = %q, want %q", plan.SourceSHA256, srcSHA) + } + if plan.SourceUser != "acct" || plan.DestinationUser != "acct" { + t.Errorf("users = %q/%q", plan.SourceUser, plan.DestinationUser) + } + if _, err := os.Stat(outMD); err != nil { + t.Errorf("markdown plan missing: %v", err) + } +} + +func TestInventoryCronPlanCmdSkipIdentical(t *testing.T) { + dir := t.TempDir() + job := accountinventory.CronJobEntry{ + Type: "schedule", Minute: "0", Hour: "3", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/true", + CommandSHA256: "sha256:abc", + Enabled: true, CommandCollected: true, + } + src := writeCronInventory(t, dir, "src.json", "source", "acct", + []accountinventory.CronJobEntry{job}, nil) + dest := writeCronInventory(t, dir, "dest.json", "destination", "acct", + []accountinventory.CronJobEntry{job}, nil) + + outJSON := filepath.Join(dir, "plan.json") + code := runInventoryCronPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", outJSON, "--output-md", filepath.Join(dir, "plan.md"), + }) + if code != 0 { + t.Fatalf("code = %d, want 0", code) + } + b, _ := os.ReadFile(outJSON) + var plan accountinventory.CronApplyPlan + if err := json.Unmarshal(b, &plan); err != nil { + t.Fatal(err) + } + if plan.Summary.Skip != 1 || plan.Summary.Create != 0 { + t.Errorf("summary = %+v, want 1 skip + 0 create", plan.Summary) + } +} + +func TestInventoryCronPlanCmdDerivedMD(t *testing.T) { + dir := t.TempDir() + src := writeCronInventory(t, dir, "src.json", "source", "acct", nil, nil) + dest := writeCronInventory(t, dir, "dest.json", "destination", "acct", nil, nil) + + outJSON := filepath.Join(dir, "my_plan.json") + code := runInventoryCronPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", outJSON, + }) + if code != 0 { + t.Fatalf("code = %d, want 0", code) + } + // Without --output-md, the markdown path is derived from the JSON path. + mdPath := filepath.Join(dir, "my_plan.md") + if _, err := os.Stat(mdPath); err != nil { + t.Errorf("derived markdown missing: %v", err) + } +} diff --git a/cmd/cpanel-self-migration/inventory_diff_cmd.go b/cmd/cpanel-self-migration/inventory_diff_cmd.go new file mode 100644 index 00000000..8b719491 --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_diff_cmd.go @@ -0,0 +1,96 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +// runInventoryDiffCmd implements `cpanel-self-migration inventory diff`: +// a fully offline, read-only comparison of two inventory JSON files. It +// never connects to any server. Exit codes: 0 = diff generated (with or +// without differences), 1 = invalid input (missing required flags, +// unreadable file, bad JSON) or write failure, 2 = unparsable flags. +func runInventoryDiffCmd(args []string) int { + fs := flag.NewFlagSet("inventory diff", flag.ContinueOnError) + source := fs.String("source", "", "path to the source inventory JSON (required)") + destination := fs.String("destination", "", "path to the destination inventory JSON (required)") + outJSON := fs.String("output-json", "inventory_diff.json", "path for the machine-readable diff") + outMD := fs.String("output-md", "inventory_diff.md", "path for the human-readable diff") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration inventory diff --source SRC.json --destination DEST.json [--output-json PATH] [--output-md PATH]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if *source == "" || *destination == "" { + fmt.Fprintln(os.Stderr, "error: --source and --destination are required") + fs.Usage() + return 1 + } + + srcInv, err := loadInventoryFile(*source) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + destInv, err := loadInventoryFile(*destination) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + d := accountinventory.DiffInventories(srcInv, destInv) + d.SourceFile = *source + d.DestinationFile = *destination + d.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + // Raw-byte hashes of the inputs: the checklist verifies the + // provenance chain against them (PR 7B). + if d.SourceSHA256, err = fileSHA256(*source); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if d.DestinationSHA256, err = fileSHA256(*destination); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + if err := accountinventory.WriteDiffJSON(*outJSON, d); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := accountinventory.WriteDiffMarkdown(*outMD, d); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + fmt.Printf("inventory diff: %d section(s) compared - %d added, %d removed, %d changed, %d warning(s)\n", + d.Summary.SectionsCompared, d.Summary.Added, d.Summary.Removed, d.Summary.Changed, d.Summary.Warnings) + fmt.Fprintf(os.Stderr, "wrote %s\nwrote %s\n", *outJSON, *outMD) + return 0 +} + +// loadInventoryFile reads and minimally validates one inventory JSON: it +// must parse and carry the account block a real inventory always has. +func loadInventoryFile(path string) (accountinventory.NormalizedInventory, error) { + var inv accountinventory.NormalizedInventory + b, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return inv, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(b, &inv); err != nil { + return inv, fmt.Errorf("parse %s: %w", path, err) + } + if inv.Account.User == "" && inv.Account.Host == "" { + return inv, fmt.Errorf("%s: not an inventory file (missing account block)", path) + } + return inv, nil +} diff --git a/cmd/cpanel-self-migration/inventory_diff_cmd_test.go b/cmd/cpanel-self-migration/inventory_diff_cmd_test.go new file mode 100644 index 00000000..e82ee611 --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_diff_cmd_test.go @@ -0,0 +1,131 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +func writeInventoryFile(t *testing.T, dir, name string, mutate func(*accountinventory.NormalizedInventory)) string { + t.Helper() + inv := accountinventory.NewEmptyInventory("u", "192.0.2.1", "source") + inv.Domains = []accountinventory.DomainEntry{{Name: "main.example", Type: "main"}} + if mutate != nil { + mutate(&inv) + } + path := filepath.Join(dir, name) + if err := accountinventory.WriteInventoryJSON(path, inv); err != nil { + t.Fatal(err) + } + return path +} + +func TestInventoryDiffCmdSuccess(t *testing.T) { + dir := t.TempDir() + src := writeInventoryFile(t, dir, "src.json", nil) + dest := writeInventoryFile(t, dir, "dest.json", func(inv *accountinventory.NormalizedInventory) { + inv.Domains = append(inv.Domains, accountinventory.DomainEntry{Name: "new.example", Type: "addon"}) + }) + outJSON := filepath.Join(dir, "diff.json") + outMD := filepath.Join(dir, "diff.md") + + code := runInventoryDiffCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", outJSON, "--output-md", outMD, + }) + if code != 0 { + t.Fatalf("exit code = %d, want 0 (differences are not an error)", code) + } + + b, err := os.ReadFile(outJSON) + if err != nil { + t.Fatalf("diff.json not written: %v", err) + } + var d map[string]any + if err := json.Unmarshal(b, &d); err != nil { + t.Fatalf("diff.json invalid: %v", err) + } + if d["mode"] != "inventory-diff" { + t.Errorf("mode = %v", d["mode"]) + } + if d["generated_at"] == nil || d["generated_at"] == "" { + t.Error("generated_at missing") + } + if _, err := os.Stat(outMD); err != nil { + t.Errorf("diff.md not written: %v", err) + } +} + +func TestInventoryDiffCmdMissingFile(t *testing.T) { + dir := t.TempDir() + src := writeInventoryFile(t, dir, "src.json", nil) + code := runInventoryDiffCmd([]string{ + "--source", src, "--destination", filepath.Join(dir, "nope.json"), + "--output-json", filepath.Join(dir, "d.json"), "--output-md", filepath.Join(dir, "d.md"), + }) + if code != 1 { + t.Errorf("exit code = %d, want 1 for missing file", code) + } +} + +func TestInventoryDiffCmdInvalidJSON(t *testing.T) { + dir := t.TempDir() + src := writeInventoryFile(t, dir, "src.json", nil) + bad := filepath.Join(dir, "bad.json") + if err := os.WriteFile(bad, []byte("{{{not json"), 0o600); err != nil { + t.Fatal(err) + } + code := runInventoryDiffCmd([]string{"--source", src, "--destination", bad}) + if code != 1 { + t.Errorf("exit code = %d, want 1 for invalid JSON", code) + } +} + +func TestInventoryDiffCmdNotAnInventory(t *testing.T) { + dir := t.TempDir() + src := writeInventoryFile(t, dir, "src.json", nil) + notInv := filepath.Join(dir, "other.json") + if err := os.WriteFile(notInv, []byte(`{"hello":"world"}`), 0o600); err != nil { + t.Fatal(err) + } + code := runInventoryDiffCmd([]string{"--source", src, "--destination", notInv}) + if code != 1 { + t.Errorf("exit code = %d, want 1 for non-inventory JSON", code) + } +} + +func TestInventoryDiffCmdMissingRequiredFlags(t *testing.T) { + if code := runInventoryDiffCmd([]string{}); code == 0 { + t.Error("missing --source/--destination must not exit 0") + } +} + +func TestInventoryDiffCmdDefaultOutputPaths(t *testing.T) { + dir := t.TempDir() + src := writeInventoryFile(t, dir, "src.json", nil) + dest := writeInventoryFile(t, dir, "dest.json", nil) + + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + + if code := runInventoryDiffCmd([]string{"--source", src, "--destination", dest}); code != 0 { + t.Fatalf("exit code = %d", code) + } + for _, f := range []string{"inventory_diff.json", "inventory_diff.md"} { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + t.Errorf("default output %s not written: %v", f, err) + } + } +} diff --git a/cmd/cpanel-self-migration/inventory_dnsplan_cmd.go b/cmd/cpanel-self-migration/inventory_dnsplan_cmd.go new file mode 100644 index 00000000..77d32c1f --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_dnsplan_cmd.go @@ -0,0 +1,171 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "crypto/sha256" + "encoding/json" + "flag" + "fmt" + "net" + "os" + "sort" + "strings" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +// ipMapFlag collects repeatable --ip-map OLD=NEW pairs. Both sides must +// be IP literals; identity mappings (X=X) are the explicit way to +// authorize copying an address verbatim. Set never returns an error: +// malformed values accumulate in errs and are reported AFTER flag +// parsing as input errors (exit 1) - a Set error would surface as +// flag.Parse's generic failure and misclassify as usage (exit 2), and +// distinguishing the two by matching the stdlib error text would be +// fragile. +type ipMapFlag struct { + m map[string]string + errs []string +} + +func (f *ipMapFlag) String() string { + keys := make([]string, 0, len(f.m)) + for k := range f.m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+f.m[k]) + } + return strings.Join(parts, ",") +} + +func (f *ipMapFlag) Set(s string) error { + from, to, ok := strings.Cut(s, "=") + switch { + case !ok || from == "" || to == "": + f.errs = append(f.errs, fmt.Sprintf("--ip-map: want OLD_IP=NEW_IP, got %q", s)) + case net.ParseIP(from) == nil: + f.errs = append(f.errs, fmt.Sprintf("--ip-map: %q is not an IP address", from)) + case net.ParseIP(to) == nil: + f.errs = append(f.errs, fmt.Sprintf("--ip-map: %q is not an IP address", to)) + default: + f.m[strings.ToLower(from)] = to + } + return nil +} + +// runInventoryDNSPlanCmd implements `cpanel-self-migration inventory +// dns-plan`: a fully offline builder of the DNS import plan (PR 6B). It +// never connects to any server and never writes anything but the two +// report files. The policy report is context, never a gate. Exit codes: +// 0 = plan generated, 1 = invalid input or write failure, 2 = flags. +func runInventoryDNSPlanCmd(args []string) int { + fs := flag.NewFlagSet("inventory dns-plan", flag.ContinueOnError) + source := fs.String("source", "", "path to the source inventory JSON (required)") + destination := fs.String("destination", "", "path to the destination inventory JSON (required)") + policyPath := fs.String("policy", "", "optional policy_report.json for context cross-references") + outJSON := fs.String("output-json", "dns_import_plan.json", "path for the machine-readable plan") + outMD := fs.String("output-md", "dns_import_plan.md", "path for the human-readable plan") + ipMap := &ipMapFlag{m: map[string]string{}} + fs.Var(ipMap, "ip-map", "OLD_IP=NEW_IP translation (repeatable; identity X=X authorizes a verbatim copy)") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration inventory dns-plan --source SRC.json --destination DEST.json [--policy POLICY.json] [--ip-map OLD=NEW ...] [--output-json PATH] [--output-md PATH]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if len(ipMap.errs) > 0 { + for _, e := range ipMap.errs { + fmt.Fprintln(os.Stderr, "error:", e) + } + return 1 + } + if *source == "" || *destination == "" { + fmt.Fprintln(os.Stderr, "error: --source and --destination are required") + fs.Usage() + return 1 + } + + srcInv, err := loadInventoryFile(*source) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + destInv, err := loadInventoryFile(*destination) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + var policy *accountinventory.PolicyReport + if *policyPath != "" { + p, err := loadPolicyFile(*policyPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + policy = &p + } + + plan, err := accountinventory.BuildDNSPlan(srcInv, destInv, policy, ipMap.m) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + plan.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + plan.SourceFile, plan.DestinationFile, plan.PolicyFile = *source, *destination, *policyPath + if plan.SourceSHA256, err = fileSHA256(*source); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if plan.DestinationSHA256, err = fileSHA256(*destination); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + if err := accountinventory.WriteDNSPlanJSON(*outJSON, plan); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := accountinventory.WriteDNSPlanMarkdown(*outMD, plan); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + fmt.Printf("dns plan: %d zone(s) - %d add, %d replace, %d manual, %d skip, %d informational\n", + len(plan.Zones), plan.Summary.Add, plan.Summary.Replace, plan.Summary.Manual, + plan.Summary.Skip, plan.Summary.Informational) + fmt.Fprintf(os.Stderr, "wrote %s\nwrote %s\n", *outJSON, *outMD) + return 0 +} + +// loadPolicyFile reads and minimally validates a policy report JSON. +func loadPolicyFile(path string) (accountinventory.PolicyReport, error) { + var p accountinventory.PolicyReport + b, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return p, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(b, &p); err != nil { + return p, fmt.Errorf("parse %s: %w", path, err) + } + if p.Mode != "inventory-policy" { + return p, fmt.Errorf("%s: not a policy report (mode %q)", path, p.Mode) + } + return p, nil +} + +// fileSHA256 hashes the raw input file so the plan can be refused by a +// future apply when its inputs changed (stale-plan defense). +func fileSHA256(path string) (string, error) { + b, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return "", fmt.Errorf("hash %s: %w", path, err) + } + return fmt.Sprintf("%x", sha256.Sum256(b)), nil +} diff --git a/cmd/cpanel-self-migration/inventory_dnsplan_cmd_test.go b/cmd/cpanel-self-migration/inventory_dnsplan_cmd_test.go new file mode 100644 index 00000000..31be97c1 --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_dnsplan_cmd_test.go @@ -0,0 +1,199 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" +) + +// writeDNSInventory writes a real inventory JSON with one DNS zone, in +// the real name format (apex absolute, non-apex relative). +func writeDNSInventory(t *testing.T, dir, name, side, zone string, records []cpanel.DNSRecord) string { + t.Helper() + inv := accountinventory.NewEmptyInventory("u", "192.0.2.1", side) + inv.DNS.Available = true + inv.DNS.Zones = []accountinventory.DNSZoneResult{ + {Available: true, Zone: zone, Method: "uapi", Records: records}, + } + b, err := json.Marshal(inv) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestInventoryDNSPlanCmdHappyPath(t *testing.T) { + dir := t.TempDir() + src := writeDNSInventory(t, dir, "src.json", "source", "example.com", + []cpanel.DNSRecord{ + {Type: "A", Name: "example.com.", TTL: 14400, Address: "203.0.113.11", Value: "203.0.113.11"}, + {Type: "A", Name: "ftp", TTL: 14400, Address: "203.0.113.11", Value: "203.0.113.11"}, + }) + dest := writeDNSInventory(t, dir, "dest.json", "destination", "example.com", + []cpanel.DNSRecord{ + {Type: "A", Name: "example.com.", TTL: 14400, Address: "203.0.113.12", Value: "203.0.113.12"}, + }) + outJSON := filepath.Join(dir, "plan.json") + outMD := filepath.Join(dir, "plan.md") + + code := runInventoryDNSPlanCmd([]string{ + "--source", src, "--destination", dest, + "--ip-map", "203.0.113.11=203.0.113.12", + "--output-json", outJSON, "--output-md", outMD, + }) + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + + b, err := os.ReadFile(outJSON) + if err != nil { + t.Fatal(err) + } + var p accountinventory.DNSPlan + if err := json.Unmarshal(b, &p); err != nil { + t.Fatal(err) + } + if p.Mode != "dns-import-plan" { + t.Errorf("mode = %q", p.Mode) + } + if p.Summary.Add != 1 || p.Summary.Skip != 1 { + t.Errorf("summary = %+v, want 1 add (ftp) + 1 skip (apex)", p.Summary) + } + // SHA-256 of the inputs must be pinned into the plan. + if len(p.SourceSHA256) != 64 || len(p.DestinationSHA256) != 64 { + t.Errorf("input hashes missing: %q %q", p.SourceSHA256, p.DestinationSHA256) + } + if p.IPMap["203.0.113.11"] != "203.0.113.12" { + t.Error("ip-map not embedded in the plan") + } + if _, err := os.Stat(outMD); err != nil { + t.Errorf("markdown not written: %v", err) + } +} + +func TestInventoryDNSPlanCmdWithPolicyContext(t *testing.T) { + dir := t.TempDir() + src := writeDNSInventory(t, dir, "src.json", "source", "example.com", + []cpanel.DNSRecord{ + {Type: "MX", Name: "example.com.", TTL: 3600, Exchange: "example.com.", Priority: 0, Value: "example.com."}, + }) + dest := writeDNSInventory(t, dir, "dest.json", "destination", "example.com", nil) + pol := accountinventory.PolicyReport{ + Mode: "inventory-policy", + OverallStatus: accountinventory.StatusBlocked, + Findings: []accountinventory.PolicyFinding{ + {ID: "POL-DNS-MX-REMOVED", Section: "dns", Severity: accountinventory.SeverityBlocker, + SourceRef: "zone example.com MX example.com."}, + }, + } + polPath := filepath.Join(dir, "policy.json") + pb, _ := json.Marshal(pol) + if err := os.WriteFile(polPath, pb, 0o600); err != nil { + t.Fatal(err) + } + outJSON := filepath.Join(dir, "plan.json") + + // A blocked policy must NOT prevent plan generation (context, not gate). + code := runInventoryDNSPlanCmd([]string{ + "--source", src, "--destination", dest, "--policy", polPath, + "--output-json", outJSON, "--output-md", filepath.Join(dir, "plan.md"), + }) + if code != 0 { + t.Fatalf("exit = %d, want 0 (policy is context, never a gate)", code) + } + b, _ := os.ReadFile(outJSON) + var p accountinventory.DNSPlan + if err := json.Unmarshal(b, &p); err != nil { + t.Fatal(err) + } + if len(p.Zones) != 1 || len(p.Zones[0].PolicyFindings) != 1 { + t.Errorf("policy findings not attached: %+v", p.Zones) + } +} + +func TestInventoryDNSPlanCmdBadIPMap(t *testing.T) { + dir := t.TempDir() + src := writeDNSInventory(t, dir, "src.json", "source", "example.com", nil) + dest := writeDNSInventory(t, dir, "dest.json", "destination", "example.com", nil) + + for _, bad := range []string{"no-equals", "192.0.2.1=", "=192.0.2.2", "notanip=192.0.2.2", "192.0.2.1=notanip"} { + code := runInventoryDNSPlanCmd([]string{ + "--source", src, "--destination", dest, "--ip-map", bad, + "--output-json", filepath.Join(dir, "p.json"), "--output-md", filepath.Join(dir, "p.md"), + }) + if code != 1 { + t.Errorf("--ip-map %q: exit = %d, want 1", bad, code) + } + } +} + +func TestInventoryDNSPlanCmdWrongModePolicy(t *testing.T) { + dir := t.TempDir() + src := writeDNSInventory(t, dir, "src.json", "source", "example.com", nil) + dest := writeDNSInventory(t, dir, "dest.json", "destination", "example.com", nil) + notPolicy := filepath.Join(dir, "not-policy.json") + if err := os.WriteFile(notPolicy, []byte(`{"mode":"inventory-diff"}`), 0o600); err != nil { + t.Fatal(err) + } + code := runInventoryDNSPlanCmd([]string{ + "--source", src, "--destination", dest, "--policy", notPolicy, + }) + if code != 1 { + t.Errorf("wrong-mode policy exit = %d, want 1", code) + } +} + +func TestInventoryDNSPlanCmdMissingFlags(t *testing.T) { + if code := runInventoryDNSPlanCmd([]string{}); code != 1 { + t.Errorf("exit = %d, want 1", code) + } + if code := runInventoryDNSPlanCmd([]string{"--no-such"}); code != 2 { + t.Errorf("bad flag exit = %d, want 2", code) + } +} + +func TestInventoryDNSPlanCmdNotAnInventory(t *testing.T) { + dir := t.TempDir() + bogus := filepath.Join(dir, "x.json") + if err := os.WriteFile(bogus, []byte(`{"mode":"other"}`), 0o600); err != nil { + t.Fatal(err) + } + code := runInventoryDNSPlanCmd([]string{"--source", bogus, "--destination", bogus}) + if code != 1 { + t.Errorf("exit = %d, want 1", code) + } +} + +func TestInventoryDNSPlanCmdMarkdownNamesManualFirst(t *testing.T) { + dir := t.TempDir() + src := writeDNSInventory(t, dir, "src.json", "source", "example.com", + []cpanel.DNSRecord{ + {Type: "A", Name: "ext", TTL: 300, Address: "203.0.113.9", Value: "203.0.113.9"}, + }) + dest := writeDNSInventory(t, dir, "dest.json", "destination", "example.com", nil) + outMD := filepath.Join(dir, "plan.md") + + code := runInventoryDNSPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", filepath.Join(dir, "plan.json"), "--output-md", outMD, + }) + if code != 0 { + t.Fatalf("exit = %d, want 0", code) + } + b, _ := os.ReadFile(outMD) + if !strings.Contains(string(b), "MANUAL") || !strings.Contains(string(b), "203.0.113.9") { + t.Error("markdown should surface the manual unmapped-address op") + } +} diff --git a/cmd/cpanel-self-migration/inventory_emailplan_cmd.go b/cmd/cpanel-self-migration/inventory_emailplan_cmd.go new file mode 100644 index 00000000..def44e01 --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_emailplan_cmd.go @@ -0,0 +1,87 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +// runInventoryEmailPlanCmd implements `cpanel-self-migration inventory +// email-plan`: a fully offline builder of the email apply plan (PR 2B-1). +// It never connects to any server and never writes anything but the two +// report files. The policy report is context, never a gate. Exit codes: +// 0 = plan generated, 1 = invalid input or write failure, 2 = flags. +func runInventoryEmailPlanCmd(args []string) int { + fs := flag.NewFlagSet("inventory email-plan", flag.ContinueOnError) + source := fs.String("source", "", "path to the source inventory JSON (required)") + destination := fs.String("destination", "", "path to the destination inventory JSON (required)") + policyPath := fs.String("policy", "", "optional policy_report.json for context cross-references") + outJSON := fs.String("output-json", "email_apply_plan.json", "path for the machine-readable plan") + outMD := fs.String("output-md", "email_apply_plan.md", "path for the human-readable plan") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration inventory email-plan --source SRC.json --destination DEST.json [--policy POLICY.json] [--output-json PATH] [--output-md PATH]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if *source == "" || *destination == "" { + fmt.Fprintln(os.Stderr, "error: --source and --destination are required") + fs.Usage() + return 1 + } + + srcInv, err := loadInventoryFile(*source) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + destInv, err := loadInventoryFile(*destination) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + var policy *accountinventory.PolicyReport + if *policyPath != "" { + p, err := loadPolicyFile(*policyPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + policy = &p + } + + plan := accountinventory.BuildEmailPlan(srcInv, destInv, policy) + plan.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + plan.SourceFile, plan.DestinationFile, plan.PolicyFile = *source, *destination, *policyPath + if plan.SourceSHA256, err = fileSHA256(*source); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if plan.DestinationSHA256, err = fileSHA256(*destination); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + if err := accountinventory.WriteEmailPlanJSON(*outJSON, plan); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := accountinventory.WriteEmailPlanMarkdown(*outMD, plan); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + fmt.Printf("email plan: %d op(s) - %d create, %d set, %d manual, %d skip, %d informational\n", + len(plan.Ops), plan.Summary.Create, plan.Summary.Set, plan.Summary.Manual, + plan.Summary.Skip, plan.Summary.Informational) + fmt.Fprintf(os.Stderr, "wrote %s\nwrote %s\n", *outJSON, *outMD) + return 0 +} diff --git a/cmd/cpanel-self-migration/inventory_emailplan_cmd_test.go b/cmd/cpanel-self-migration/inventory_emailplan_cmd_test.go new file mode 100644 index 00000000..65e677b4 --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_emailplan_cmd_test.go @@ -0,0 +1,101 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +func writeEmailPlanInventory(t *testing.T, dir, name, side, user string, fwds []accountinventory.NormForwarderEntry, defaults []accountinventory.DefaultAddressEntry) string { + t.Helper() + inv := accountinventory.NewEmptyInventory(user, "192.0.2.1", side) + inv.Domains = []accountinventory.DomainEntry{{Name: "example.com", Type: "main"}} + inv.Forwarders = fwds + inv.DefaultAddresses.Available = true + inv.DefaultAddresses.Items = defaults + inv.EmailRouting.Available = true + inv.EmailFilters.Available = true + b, err := json.MarshalIndent(inv, "", " ") + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestInventoryEmailPlanCmdFlagAndInputErrors(t *testing.T) { + if code := runInventoryEmailPlanCmd([]string{"--bogus"}); code != 2 { + t.Errorf("unknown flag: code = %d, want 2", code) + } + if code := runInventoryEmailPlanCmd([]string{}); code != 1 { + t.Errorf("missing required flags: code = %d, want 1", code) + } + dir := t.TempDir() + src := writeEmailPlanInventory(t, dir, "src.json", "source", "acct", nil, nil) + if code := runInventoryEmailPlanCmd([]string{"--source", src, "--destination", filepath.Join(dir, "nope.json")}); code != 1 { + t.Errorf("nonexistent destination: code = %d, want 1", code) + } +} + +func TestInventoryEmailPlanCmdEndToEnd(t *testing.T) { + dir := t.TempDir() + src := writeEmailPlanInventory(t, dir, "src.json", "source", "acct", + []accountinventory.NormForwarderEntry{ + {Source: "info@example.com", Destination: "someone@gmail.com", Domain: "example.com"}, + }, + []accountinventory.DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "someone@gmail.com"}, + }) + dest := writeEmailPlanInventory(t, dir, "dest.json", "destination", "acct", nil, + []accountinventory.DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "acct"}, + }) + outJSON := filepath.Join(dir, "email_apply_plan.json") + outMD := filepath.Join(dir, "email_apply_plan.md") + + code := runInventoryEmailPlanCmd([]string{ + "--source", src, "--destination", dest, + "--output-json", outJSON, "--output-md", outMD, + }) + if code != 0 { + t.Fatalf("code = %d, want 0", code) + } + + b, err := os.ReadFile(outJSON) + if err != nil { + t.Fatal(err) + } + var plan accountinventory.EmailApplyPlan + if err := json.Unmarshal(b, &plan); err != nil { + t.Fatal(err) + } + if plan.Mode != "email-apply-plan" || plan.FormatVersion != 1 { + t.Errorf("mode/version = %q/%d", plan.Mode, plan.FormatVersion) + } + if plan.Summary.Create != 1 || plan.Summary.Set != 1 { + t.Errorf("summary = %+v, want 1 create + 1 set", plan.Summary) + } + // The embedded hashes must match the raw input files (stale-plan defense). + srcSHA, err := fileSHA256(src) + if err != nil { + t.Fatal(err) + } + if plan.SourceSHA256 != srcSHA { + t.Errorf("source_sha256 = %q, want %q", plan.SourceSHA256, srcSHA) + } + if plan.SourceUser != "acct" || plan.DestinationUser != "acct" { + t.Errorf("users = %q/%q", plan.SourceUser, plan.DestinationUser) + } + if _, err := os.Stat(outMD); err != nil { + t.Errorf("markdown plan missing: %v", err) + } +} diff --git a/cmd/cpanel-self-migration/inventory_policy_cmd.go b/cmd/cpanel-self-migration/inventory_policy_cmd.go new file mode 100644 index 00000000..7b8c8d06 --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_policy_cmd.go @@ -0,0 +1,98 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +// runInventoryPolicyCmd implements `cpanel-self-migration inventory +// policy`: a fully offline, deterministic classification of an +// inventory_diff.json into a migration-readiness report. It never +// connects to any server and never applies anything. Exit codes: +// 0 = report generated (blockers are NOT a process error), 1 = invalid +// input (missing required flags, unreadable file, bad JSON) or write +// failure, 2 = unparsable flags, 3 = --fail-on-blockers was set and the +// overall status is "blocked" (reports are still fully written first). +// exitBlockedGate is the process exit code emitted when +// --fail-on-blockers is set and the overall status is blocked. It is +// part of the CLI contract (documented in docs/COMMAND.md); tests assert +// the literal value on purpose so a change here fails loudly. +const exitBlockedGate = 3 + +func runInventoryPolicyCmd(args []string) int { + fs := flag.NewFlagSet("inventory policy", flag.ContinueOnError) + diffPath := fs.String("diff", "", "path to the inventory_diff.json to classify (required)") + outJSON := fs.String("output-json", "policy_report.json", "path for the machine-readable report") + outMD := fs.String("output-md", "policy_report.md", "path for the human-readable report") + failOnBlockers := fs.Bool("fail-on-blockers", false, "exit with code 3 when the overall status is blocked (for CI gating)") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration inventory policy --diff DIFF.json [--output-json PATH] [--output-md PATH] [--fail-on-blockers]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if *diffPath == "" { + fmt.Fprintln(os.Stderr, "error: --diff is required") + fs.Usage() + return 1 + } + + d, err := loadDiffFile(*diffPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + r := accountinventory.EvaluatePolicy(d) + r.InputDiff = *diffPath + r.GeneratedAt = time.Now().UTC().Format(time.RFC3339) + // Raw-byte hash of the consumed diff: the checklist verifies the + // provenance chain against it (PR 7B). + if r.InputDiffSHA256, err = fileSHA256(*diffPath); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + if err := accountinventory.WritePolicyJSON(*outJSON, r); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + if err := accountinventory.WritePolicyMarkdown(*outMD, r); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + fmt.Printf("inventory policy: %s - %d blocker(s), %d review(s), %d warning(s), %d info\n", + r.OverallStatus, r.Summary.Blockers, r.Summary.Reviews, r.Summary.Warnings, r.Summary.Info) + fmt.Fprintf(os.Stderr, "wrote %s\nwrote %s\n", *outJSON, *outMD) + if *failOnBlockers && r.OverallStatus == accountinventory.StatusBlocked { + fmt.Fprintf(os.Stderr, "fail-on-blockers: overall status is blocked, exiting %d\n", exitBlockedGate) + return exitBlockedGate + } + return 0 +} + +// loadDiffFile reads and minimally validates an inventory diff JSON. +func loadDiffFile(path string) (accountinventory.InventoryDiff, error) { + var d accountinventory.InventoryDiff + b, err := os.ReadFile(path) // #nosec G304 -- user-supplied CLI input path, read-only + if err != nil { + return d, fmt.Errorf("read %s: %w", path, err) + } + if err := json.Unmarshal(b, &d); err != nil { + return d, fmt.Errorf("parse %s: %w", path, err) + } + if d.Mode != "inventory-diff" { + return d, fmt.Errorf("%s: not an inventory diff file (mode %q)", path, d.Mode) + } + return d, nil +} diff --git a/cmd/cpanel-self-migration/inventory_policy_cmd_test.go b/cmd/cpanel-self-migration/inventory_policy_cmd_test.go new file mode 100644 index 00000000..57073cc0 --- /dev/null +++ b/cmd/cpanel-self-migration/inventory_policy_cmd_test.go @@ -0,0 +1,186 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" +) + +// writeDiffFileFor produces a real inventory_diff.json by running the real +// pipeline: two inventories -> DiffInventories -> WriteDiffJSON. +func writeDiffFileFor(t *testing.T, dir string, mutate func(dest *accountinventory.NormalizedInventory)) string { + t.Helper() + src := accountinventory.NewEmptyInventory("u", "192.0.2.1", "source") + src.Domains = []accountinventory.DomainEntry{{Name: "main.example", Type: "main"}} + src.Mailboxes = []accountinventory.MailboxEntry{{Email: "info@main.example", Domain: "main.example", User: "info"}} + src.Forwarders = []accountinventory.NormForwarderEntry{{Source: "fwd@main.example", Destination: "info@main.example", Domain: "main.example"}} + // Mark every config section available on both sides: an unavailable + // section emits POL-SECTION-UNAVAILABLE (review), so without this the + // no-mutation fixture could never reach "ready". + src.FTP.Available = true + src.SSL.Available = true + src.PHP.Available = true + src.DNS.Available = true + src.Cron.Available = true + src.EmailRouting.Available = true + src.DefaultAddresses.Available = true + src.EmailFilters.Available = true + src.Redirects.Available = true + dest := src + dest.Mailboxes = append([]accountinventory.MailboxEntry{}, src.Mailboxes...) + dest.Forwarders = append([]accountinventory.NormForwarderEntry{}, src.Forwarders...) + if mutate != nil { + mutate(&dest) + } + d := accountinventory.DiffInventories(src, dest) + d.SourceFile, d.DestinationFile, d.GeneratedAt = "s.json", "d.json", "t" + path := filepath.Join(dir, "inventory_diff.json") + if err := accountinventory.WriteDiffJSON(path, d); err != nil { + t.Fatal(err) + } + return path +} + +// readOverallStatus parses the JSON policy report and returns its +// overall_status, failing the test if the file is missing or malformed. +func readOverallStatus(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("policy JSON report not readable: %v", err) + } + var r struct { + OverallStatus string `json:"overall_status"` + } + if err := json.Unmarshal(b, &r); err != nil { + t.Fatalf("policy JSON report not parsable: %v", err) + } + return r.OverallStatus +} + +func TestInventoryPolicyCmdWithBlockerExitsZero(t *testing.T) { + dir := t.TempDir() + diff := writeDiffFileFor(t, dir, func(dest *accountinventory.NormalizedInventory) { + dest.Mailboxes = nil // mailbox removed -> blocker + }) + outJSON := filepath.Join(dir, "policy.json") + outMD := filepath.Join(dir, "policy.md") + + code := runInventoryPolicyCmd([]string{"--diff", diff, "--output-json", outJSON, "--output-md", outMD}) + if code != 0 { + t.Fatalf("exit = %d, want 0 (blockers are findings, not process errors)", code) + } + if got := readOverallStatus(t, outJSON); got != "blocked" { + t.Errorf("overall_status = %v, want blocked", got) + } + md, err := os.ReadFile(outMD) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(md), "POL-MAILBOX-REMOVED") { + t.Error("markdown missing the blocker finding") + } +} + +func TestInventoryPolicyCmdFailOnBlockersBlockedExitsThree(t *testing.T) { + dir := t.TempDir() + diff := writeDiffFileFor(t, dir, func(dest *accountinventory.NormalizedInventory) { + dest.Mailboxes = nil // mailbox removed -> blocker + }) + outJSON := filepath.Join(dir, "policy.json") + outMD := filepath.Join(dir, "policy.md") + + code := runInventoryPolicyCmd([]string{"--diff", diff, "--fail-on-blockers", "--output-json", outJSON, "--output-md", outMD}) + // The literal 3 is asserted on purpose: the exit code is CLI contract + // (docs/COMMAND.md), so a change to exitBlockedGate must fail here. + if code != 3 { + t.Fatalf("exit = %d, want 3 (blocked status must gate with --fail-on-blockers)", code) + } + // The gate must NOT suppress report generation: both artifacts exist + // and the JSON still records the blocked status. + if got := readOverallStatus(t, outJSON); got != "blocked" { + t.Errorf("overall_status = %v, want blocked", got) + } + if _, err := os.Stat(outMD); err != nil { + t.Errorf("markdown report not written before gating exit: %v", err) + } +} + +func TestInventoryPolicyCmdFailOnBlockersReadyExitsZero(t *testing.T) { + dir := t.TempDir() + diff := writeDiffFileFor(t, dir, nil) // identical inventories -> ready + outJSON := filepath.Join(dir, "policy.json") + + code := runInventoryPolicyCmd([]string{"--diff", diff, "--fail-on-blockers", + "--output-json", outJSON, "--output-md", filepath.Join(dir, "policy.md")}) + if code != 0 { + t.Errorf("exit = %d, want 0 (ready must not gate)", code) + } + if got := readOverallStatus(t, outJSON); got != "ready" { + t.Fatalf("fixture produced overall_status = %v, want ready (test would be vacuous)", got) + } +} + +func TestInventoryPolicyCmdFailOnBlockersReviewRequiredExitsZero(t *testing.T) { + dir := t.TempDir() + diff := writeDiffFileFor(t, dir, func(dest *accountinventory.NormalizedInventory) { + dest.Forwarders = nil // forwarder removed -> review, not blocker + }) + outJSON := filepath.Join(dir, "policy.json") + + code := runInventoryPolicyCmd([]string{"--diff", diff, "--fail-on-blockers", + "--output-json", outJSON, "--output-md", filepath.Join(dir, "policy.md")}) + if code != 0 { + t.Fatalf("exit = %d, want 0 (--fail-on-blockers gates only blocked, not review_required)", code) + } + if got := readOverallStatus(t, outJSON); got != "review_required" { + t.Fatalf("fixture produced overall_status = %v, want review_required (test would be vacuous)", got) + } +} + +func TestInventoryPolicyCmdMissingFile(t *testing.T) { + if code := runInventoryPolicyCmd([]string{"--diff", "/nonexistent/diff.json"}); code != 1 { + t.Errorf("exit = %d, want 1", code) + } +} + +func TestInventoryPolicyCmdInvalidJSON(t *testing.T) { + dir := t.TempDir() + bad := filepath.Join(dir, "bad.json") + if err := os.WriteFile(bad, []byte("{{{"), 0o600); err != nil { + t.Fatal(err) + } + if code := runInventoryPolicyCmd([]string{"--diff", bad}); code != 1 { + t.Errorf("exit = %d, want 1", code) + } +} + +func TestInventoryPolicyCmdNotADiff(t *testing.T) { + dir := t.TempDir() + notDiff := filepath.Join(dir, "other.json") + if err := os.WriteFile(notDiff, []byte(`{"mode":"something-else"}`), 0o600); err != nil { + t.Fatal(err) + } + if code := runInventoryPolicyCmd([]string{"--diff", notDiff}); code != 1 { + t.Errorf("exit = %d, want 1", code) + } +} + +func TestInventoryPolicyCmdBadFlags(t *testing.T) { + if code := runInventoryPolicyCmd([]string{"--no-such-flag"}); code != 2 { + t.Errorf("exit = %d, want 2", code) + } +} + +func TestInventoryPolicyCmdMissingDiffFlag(t *testing.T) { + if code := runInventoryPolicyCmd([]string{}); code != 1 { + t.Errorf("exit = %d, want 1", code) + } +} diff --git a/cmd/cpanel-self-migration/main.go b/cmd/cpanel-self-migration/main.go index f51000f8..1c0d526a 100644 --- a/cmd/cpanel-self-migration/main.go +++ b/cmd/cpanel-self-migration/main.go @@ -15,16 +15,109 @@ import ( "os/signal" "path/filepath" "strings" + "sync" "syscall" + "time" + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory/batch" "github.com/tis24dev/cPanel_self-migration/internal/config" + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "github.com/tis24dev/cPanel_self-migration/internal/events" "github.com/tis24dev/cPanel_self-migration/internal/logx" "github.com/tis24dev/cPanel_self-migration/internal/migrate" + "github.com/tis24dev/cPanel_self-migration/internal/sshx" "github.com/tis24dev/cPanel_self-migration/internal/validate" "github.com/tis24dev/cPanel_self-migration/internal/version" ) func main() { + // The `inventory ...` subcommands are fully offline and share none of + // the migration flags. Dispatch happens before flag.Parse so a missing + // or unknown subcommand hard-exits 2 and never falls through to a + // migration dry-run. No write-namespace run* symbol is reachable from + // this batch: the read commands physically cannot enter an apply flow. + if len(os.Args) >= 2 && os.Args[1] == "inventory" { + if len(os.Args) >= 3 { + switch os.Args[2] { + case "diff": + os.Exit(runInventoryDiffCmd(os.Args[3:])) + case "policy": + os.Exit(runInventoryPolicyCmd(os.Args[3:])) + case "dns-plan": + os.Exit(runInventoryDNSPlanCmd(os.Args[3:])) + case "email-plan": + os.Exit(runInventoryEmailPlanCmd(os.Args[3:])) + case "cron-plan": + os.Exit(runInventoryCronPlanCmd(os.Args[3:])) + case "checklist": + os.Exit(runInventoryChecklistCmd(os.Args[3:])) + } + } + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration inventory ...") + os.Exit(2) + } + // The `migration` namespace is the offline session-governance layer: + // local management of migration sessions (init, list, show, set-status, + // attach-artifact, archive) over a filesystem session store. It is + // read/local-only, never dials a host, and never falls through to the + // migration flow. + if len(os.Args) >= 2 && os.Args[1] == "migration" { + os.Exit(runMigrationCmd(os.Args[2:])) + } + // The `dns` namespace is the first WRITE vertical (Batch D1): it applies + // and verifies DNS zone changes on the DESTINATION only. Both subcommands + // are preview-by-default: `dns apply` writes nothing without + // --yes-apply-writes, and `dns verify` is read-only. Dispatch happens + // before flag.Parse so a missing or unknown subcommand hard-exits 2 and + // never falls through to a migration dry-run. + if len(os.Args) >= 2 && os.Args[1] == "dns" { + if len(os.Args) >= 3 { + switch os.Args[2] { + case "verify": + os.Exit(runDNSVerifyCmd(os.Args[3:])) + case "apply": + os.Exit(runDNSApplyCmd(os.Args[3:])) + } + } + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration dns --plan dns_import_plan.json ...") + os.Exit(2) + } + // The `cron` namespace is the second WRITE vertical (Batch D3): it applies + // and verifies crontab changes on the DESTINATION only, via the whole-crontab + // `crontab -` primitive. Same preview-by-default contract as `dns`: `cron + // apply` writes nothing without --yes-apply-writes, `cron verify` is + // read-only. Dispatched before flag.Parse for the same hard-exit reason. + if len(os.Args) >= 2 && os.Args[1] == "cron" { + if len(os.Args) >= 3 { + switch os.Args[2] { + case "verify": + os.Exit(runCronVerifyCmd(os.Args[3:])) + case "apply": + os.Exit(runCronApplyCmd(os.Args[3:])) + } + } + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration cron --plan cron_apply_plan.json ...") + os.Exit(2) + } + // The `email` namespace is the third WRITE vertical (Batch D2): it applies + // and verifies email-config changes (forwarders, catch-all, autoresponders, + // filters, routing) on the DESTINATION only. Credential-free by design: + // mailbox creation/passwords stay in the migrate flow. Same preview-by- + // default contract as dns/cron: `email apply` writes nothing without + // --yes-apply-writes, `email verify` is read-only. + if len(os.Args) >= 2 && os.Args[1] == "email" { + if len(os.Args) >= 3 { + switch os.Args[2] { + case "verify": + os.Exit(runEmailVerifyCmd(os.Args[3:])) + case "apply": + os.Exit(runEmailApplyCmd(os.Args[3:])) + } + } + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration email --plan email_apply_plan.json ...") + os.Exit(2) + } var ( apply = flag.Bool("apply", false, "create missing domains + migrate the selected data (default: dry-run)") dryRun = flag.Bool("dry-run", false, "explicit dry-run: analyze + compare SRC/DEST, no changes") @@ -37,10 +130,16 @@ func main() { forceSync = flag.Bool("force-sync", false, "alias of --full") applyMirror = flag.Bool("apply-mirror", false, "like --apply, but MIRROR each mailbox: rename the destination mailbox aside (-bak) and re-copy ALL messages from the source, so mail that exists only on the dest is removed from the live mailbox. Files/databases behave as under --apply.") verifyCsum = flag.Bool("verify-checksums", false, "stricter fast-skip: when count+UIDVALIDITY match, also compare the exact message-ID set before skipping a mailbox") - deepVerify = flag.Bool("deep-verify", false, "with --apply, verify by CONTENT hash (sha256 per web file; slower, reads every byte on both sides) instead of metadata only — catches same-size corruption") + deepVerify = flag.Bool("deep-verify", false, "with --apply, verify by CONTENT hash (sha256 per web file; slower, reads every byte on both sides) instead of metadata only, catches same-size corruption") cfgPath = flag.String("config", "", "path to host.yaml (default: configs/host.yaml next to the binary or CWD)") logLevel = flag.String("log-level", "info", "log verbosity: info | debug (debug traces SSH sessions, transfers, and network errors to stderr)") showVersion = flag.Bool("version", false, "print the version and exit") + runID = flag.String("run-id", "", "optional run identifier for structured output (default: auto-generated)") + outputDir = flag.String("output-dir", "", "output directory for artifacts (default: current working directory)") + jsonEvents = flag.Bool("json-events", false, "write JSONL events to /events.jsonl") + reportJSON = flag.Bool("report-json", false, "write JSON report to /report.json") + + accountInventory = flag.Bool("account-inventory", false, "collect a read-only account inventory and exit (no migration)") ) flag.Usage = usage flag.Parse() @@ -72,6 +171,20 @@ func main() { fmt.Fprintln(os.Stderr, "error: --apply and --apply-mirror are mutually exclusive") os.Exit(2) } + if *accountInventory { + if *apply || *applyMirror { + fmt.Fprintln(os.Stderr, "error: --account-inventory is mutually exclusive with --apply/--apply-mirror") + os.Exit(2) + } + if *mailFlag || *fileFlag || *dbFlag { + fmt.Fprintln(os.Stderr, "error: --account-inventory collects the full account; do not combine with --mail/--file/--db") + os.Exit(2) + } + if *onlyDomain != "" || *onlyMailbox != "" { + fmt.Fprintln(os.Stderr, "error: --account-inventory collects the full account; do not combine with --domain/--mailbox") + os.Exit(2) + } + } if err := validateScopeFilters(*onlyDomain, *onlyMailbox, *mailFlag, *fileFlag, *dbFlag); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(2) @@ -82,7 +195,7 @@ func main() { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } - // Always announce WHICH config is in effect — a stale installed host.yaml silently + // Always announce WHICH config is in effect: a stale installed host.yaml silently // shadowing the intended one would otherwise migrate the wrong accounts unnoticed. fmt.Fprintf(os.Stderr, "Config: %s\n", path) if len(altConfigs) > 0 { @@ -95,14 +208,14 @@ func main() { os.Exit(1) } - // Signal handling — a SINGLE handler so the two interrupts behave + // Signal handling: a SINGLE handler so the two interrupts behave // deterministically: // 1st Ctrl-C -> cancel ctx (in-flight work stops; deferred cleanups like // token revoke and connection close run to completion); // 2nd Ctrl-C -> force-exit, for the rare case the cleanup itself wedges. // Using ONE handler (not NotifyContext + a second signal.Notify on the same // signals) avoids a race where the second Ctrl-C could os.Exit before the - // first interrupt's cleanup — e.g. token revocation — has finished. + // first interrupt's cleanup (e.g. token revocation) has finished. ctx, cancel := context.WithCancel(context.Background()) defer cancel() go handleSignals(cancel) @@ -143,6 +256,50 @@ func main() { fmt.Fprintln(os.Stderr, "error: cannot determine the current working directory (needed for the logs/ artifacts):", err) os.Exit(1) } + if *outputDir != "" { + outDir = *outputDir + } + if *runID != "" { + if err := events.ValidateRunID(*runID); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(2) + } + } + // The emitter always feeds the phase collector (report.json's + // phases_completed works with --report-json alone) and tees to the JSONL + // writer only when --json-events is set. + collector := newPhaseCollector() + em := events.Emitter{Emit: collector.observe} + if *jsonEvents { + evPath := filepath.Join(outDir, "events.jsonl") + ew, err := events.NewWriter(evPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error: cannot create events file:", err) + os.Exit(1) + } + defer func() { _ = ew.Close() }() + var evWriteErr sync.Once + em = events.Emitter{Emit: func(e events.Event) { + collector.observe(e) + if err := ew.Write(e); err != nil { + evWriteErr.Do(func() { + fmt.Fprintln(os.Stderr, "warning: events.jsonl write error:", err) + }) + } + }} + } + if *accountInventory { + rid := *runID + if rid == "" { + rid = events.NewRunID(time.Now()) + } + if err := runAccountInventory(ctx, cfg, outDir, rid, em, *reportJSON); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + return + } + startedAt := time.Now() opts := migrate.Options{ Apply: *apply || *applyMirror, ForceSync: *full || *forceSync, @@ -155,13 +312,24 @@ func main() { OnlyDomain: *onlyDomain, OnlyMailbox: *onlyMailbox, OutputDir: outDir, + RunID: *runID, + Events: em, + Now: startedAt, } - - if err := migrate.Run(ctx, cfg, opts); err != nil { + runErr := migrate.Run(ctx, cfg, opts) + finishedAt := time.Now() + if *reportJSON { + rpt := buildRunReport(opts, cfg, startedAt, finishedAt, runErr, ctx.Err(), + collector.completed(), runArtifacts(outDir, collector.applyPhaseSeen(), *jsonEvents)) + if werr := events.WriteReport(filepath.Join(outDir, "report.json"), rpt); werr != nil { + fmt.Fprintln(os.Stderr, "warning: could not write report.json:", werr) + } + } + if err := runErr; err != nil { if ctx.Err() != nil { // Interrupted: report cleanly with a distinct exit code (130 = // terminated by Ctrl-C, the shell convention). - fmt.Fprintln(os.Stderr, "\ninterrupted — stopped; no further changes will be made.") + fmt.Fprintln(os.Stderr, "\ninterrupted, stopped; no further changes will be made.") os.Exit(130) } fmt.Fprintln(os.Stderr, "error:", err) @@ -170,8 +338,8 @@ func main() { } // handleSignals is the SINGLE interrupt handler. The first SIGINT/SIGTERM cancels -// the run (via cancel), letting in-flight work stop and deferred cleanups — token -// revoke, connection close — run to completion; it prints a notice telling the +// the run (via cancel), letting in-flight work stop and deferred cleanups (token +// revoke, connection close) run to completion; it prints a notice telling the // user a second Ctrl-C will force-quit. The second signal force-exits, for the // rare case the cleanup itself wedges. // @@ -183,7 +351,7 @@ func handleSignals(cancel context.CancelFunc) { signal.Notify(ch, os.Interrupt, syscall.SIGTERM) <-ch // first interrupt: ask for a clean shutdown - fmt.Fprintln(os.Stderr, "\ninterrupting — finishing the current step and shutting down cleanly (press Ctrl-C again to force quit) ...") + fmt.Fprintln(os.Stderr, "\ninterrupting, finishing the current step and shutting down cleanly (press Ctrl-C again to force quit) ...") cancel() <-ch // second interrupt: give up and exit now @@ -191,6 +359,250 @@ func handleSignals(cancel context.CancelFunc) { os.Exit(130) } +// runAccountInventory collects a read-only inventory of the SOURCE (and the +// DESTINATION when configured) and writes the artifacts. It never writes to +// any server: it dials both hosts read-only, hands the runners to +// accountinventory.Collect, and persists the resulting JSON + Markdown. This +// is where the collectors go live. +func runAccountInventory(ctx context.Context, cfg config.Config, outDir, runID string, em events.Emitter, writeReportJSON bool) error { + startedAt := time.Now() + srcRef := events.HostRef{IP: cfg.Src.IP, User: cfg.Src.SSHUser} + em.Send(events.Event{ + RunID: runID, TS: startedAt, + Level: events.LevelInfo, Type: events.EventRunStarted, + Message: "account inventory started", + Source: srcRef, + }) + + fail := func(err error) error { + em.Send(events.Event{ + RunID: runID, TS: time.Now(), + Level: events.LevelError, Type: events.EventRunFailed, + Message: err.Error(), Source: srcRef, + }) + return err + } + + pool, err := sshx.DialBoth(ctx, cfg, "") + if err != nil { + return fail(err) + } + defer func() { _ = pool.Close() }() + + srcInfo := accountinventory.HostInfo{User: cfg.Src.SSHUser, Host: cfg.Src.IP} + var destInfo accountinventory.HostInfo + var destRunner cpanel.Runner + if cfg.DestConfigured() { + destInfo = accountinventory.HostInfo{User: cfg.Dest.SSHUser, Host: cfg.Dest.IP} + destRunner = pool.Dest + } + + // Prefetch/replay: collect each side's read-only inventory in 3 batched + // execs and replay them to the UNMODIFIED collector. On any prefetch error + // fall back to the direct client (correct, just more round-trips). + var srcRunner cpanel.Runner = pool.Src + if r, perr := batch.Prefetch(ctx, pool.Src, pool.Src); perr != nil { + logx.Warn("source inventory prefetch failed, using direct calls: %v", perr) + } else { + srcRunner = r + } + if destRunner != nil { + if r, perr := batch.Prefetch(ctx, pool.Dest, pool.Dest); perr != nil { + logx.Warn("destination inventory prefetch failed, using direct calls: %v", perr) + } else { + destRunner = r + } + } + + result, err := accountinventory.Collect(ctx, srcRunner, destRunner, srcInfo, destInfo) + if err != nil { + return fail(fmt.Errorf("inventory collection: %w", err)) + } + + srcPath := filepath.Join(outDir, "inventory_source.json") + if err := accountinventory.WriteInventoryJSON(srcPath, result.Source); err != nil { + return fail(fmt.Errorf("write source inventory: %w", err)) + } + fmt.Fprintf(os.Stderr, "wrote %s\n", srcPath) + + if result.Dest != nil { + destPath := filepath.Join(outDir, "inventory_destination.json") + if err := accountinventory.WriteInventoryJSON(destPath, *result.Dest); err != nil { + return fail(fmt.Errorf("write dest inventory: %w", err)) + } + fmt.Fprintf(os.Stderr, "wrote %s\n", destPath) + } + + reportPath := filepath.Join(outDir, "inventory_report.md") + if err := accountinventory.WriteReport(reportPath, result); err != nil { + return fail(fmt.Errorf("write report: %w", err)) + } + fmt.Fprintf(os.Stderr, "wrote %s\n", reportPath) + + if writeReportJSON { + var destRef events.HostRef + if cfg.DestConfigured() { + destRef = events.HostRef{IP: cfg.Dest.IP, User: cfg.Dest.SSHUser} + } + rpt := events.RunReport{ + RunID: runID, + Version: version.String(), + Mode: "account-inventory", + Source: srcRef, + Dest: destRef, + StartedAt: startedAt, + FinishedAt: time.Now(), + ExitStatus: events.ExitSuccess, + PhasesCompleted: []events.Phase{}, + Warnings: accountinventory.AggregateWarnings(result), + Errors: []string{}, + } + rptPath := filepath.Join(outDir, "report.json") + if werr := events.WriteReport(rptPath, rpt); werr != nil { + fmt.Fprintln(os.Stderr, "warning: could not write report.json:", werr) + } + } + + em.Send(events.Event{ + RunID: runID, TS: time.Now(), + Level: events.LevelInfo, Type: events.EventRunCompleted, + Message: "account inventory completed", + }) + return nil +} + +func buildRunReport(opts migrate.Options, cfg config.Config, startedAt, finishedAt time.Time, runErr, ctxErr error, phases []events.Phase, artifacts map[string]string) events.RunReport { + mode := "dry-run" + if opts.Apply { + mode = "apply" + } + status := events.ExitSuccess + errs := []string{} + if runErr != nil { + if ctxErr != nil { + status = events.ExitInterrupted + } else { + status = events.ExitFailed + } + errs = append(errs, runErr.Error()) + } + runID := opts.RunID + if runID == "" { + runID = events.NewRunID(opts.Now) + } + if phases == nil { + phases = []events.Phase{} + } + return events.RunReport{ + RunID: runID, + Version: version.String(), + Mode: mode, + Scope: events.ReportScope{ + Mail: opts.DoMail, + Files: opts.DoFile, + Databases: opts.DoDB, + DomainFilter: opts.OnlyDomain, + MailboxFilter: opts.OnlyMailbox, + }, + Source: events.HostRef{IP: cfg.Src.IP, User: cfg.Src.SSHUser}, + Dest: events.HostRef{IP: cfg.Dest.IP, User: cfg.Dest.SSHUser}, + StartedAt: startedAt, + FinishedAt: finishedAt, + ExitStatus: status, + PhasesCompleted: phases, + Warnings: []string{}, + Errors: errs, + Artifacts: artifacts, + } +} + +// phaseCollector records completed phases from the event stream, in +// first-completion order and deduplicated, for report.json's +// phases_completed. It also tracks whether ANY apply-phase event was +// observed (started/completed/failed), proof this run actually entered +// runApply. Mutex-guarded so a future concurrent emitter cannot corrupt it +// (the race CI job is authoritative). +type phaseCollector struct { + mu sync.Mutex + seen map[events.Phase]bool + phases []events.Phase + applySeen bool +} + +func newPhaseCollector() *phaseCollector { + return &phaseCollector{seen: map[events.Phase]bool{}} +} + +// applyPhases is the set of phases only runApply emits. +var applyPhases = map[events.Phase]bool{ + events.PhaseCreateDomains: true, + events.PhaseMigrateMail: true, events.PhaseVerifyMail: true, + events.PhaseCopyFiles: true, events.PhaseVerifyFiles: true, + events.PhaseMigrateDB: true, events.PhaseVerifyDB: true, +} + +// observe records e when it is a phase_completed event for a named phase; +// run-level events (no phase) and every other event type are ignored for +// phases_completed, but any apply-phase event marks the run as having +// entered the apply flow. +func (c *phaseCollector) observe(e events.Event) { + if e.Phase == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if applyPhases[e.Phase] { + c.applySeen = true + } + if e.Type != events.EventPhaseCompleted || c.seen[e.Phase] { + return + } + c.seen[e.Phase] = true + c.phases = append(c.phases, e.Phase) +} + +// completed returns a copy of the recorded phases. +func (c *phaseCollector) completed() []events.Phase { + c.mu.Lock() + defer c.mu.Unlock() + return append([]events.Phase{}, c.phases...) +} + +// applyPhaseSeen reports whether this run emitted any apply-phase event. +func (c *phaseCollector) applyPhaseSeen() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.applySeen +} + +// runArtifacts records the run's artifact files in report.json, by +// EXISTENCE CHECK only, never on trust. The migration report log is +// claimed only when THIS run provably entered the apply flow +// (applyPhaseSeen): migration_report.log has a fixed name in outDir, so an +// apply run that failed before runApply (connect/analyze error) must not +// claim a stale log left by a previous run. events.jsonl is recorded only +// when --json-events was set (this run created it). +func runArtifacts(outDir string, applyPhaseSeen, jsonEvents bool) map[string]string { + arts := map[string]string{} + if applyPhaseSeen { + if p := filepath.Join(outDir, "logs", "migration_report.log"); fileIsRegular(p) { + arts["migration_report_log"] = p + } + } + if jsonEvents { + if p := filepath.Join(outDir, "events.jsonl"); fileIsRegular(p) { + arts["events_jsonl"] = p + } + } + return arts +} + +// fileIsRegular reports whether path exists and is a regular file. +func fileIsRegular(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + // resolveConfigPath finds host.yaml when --config is not given. It looks, in // order, under configs/ and then the bare directory, relative to both the binary's // directory and the current working directory, and returns the FIRST match plus any @@ -279,13 +691,13 @@ func validateScopeFilters(onlyDomain, onlyMailbox string, mailFlag, fileFlag, db } func usage() { - fmt.Fprintf(os.Stderr, `cpanel-self-migration — cPanel email + website-file + database migration tool + fmt.Fprintf(os.Stderr, `cpanel-self-migration: cPanel email + website-file + database migration tool -Usage: %s [--apply|--apply-mirror|--dry-run] [--mail] [--file] [--db] [--domain DOMAIN] [--mailbox ADDR] [--full] [--verify-checksums] [--deep-verify] [--config PATH] [--log-level LEVEL] +Usage: %s [--apply|--apply-mirror|--dry-run|--account-inventory] [--mail] [--file] [--db] [--domain DOMAIN] [--mailbox ADDR] [--full] [--verify-checksums] [--deep-verify] [--config PATH] [--log-level LEVEL] [--run-id ID] [--output-dir DIR] [--json-events] [--report-json] (default) dry-run : analyze + compare SOURCE/DEST, no changes --apply : create missing domains + migrate the selected data - --apply-mirror : like --apply, but MIRROR each mailbox — rename the dest + --apply-mirror : like --apply, but MIRROR each mailbox, rename the dest mailbox aside (-bak) and re-copy ALL messages, so mail existing only on the dest is removed from the live mailbox; files/databases behave as under --apply @@ -293,7 +705,7 @@ Usage: %s [--apply|--apply-mirror|--dry-run] [--mail] [--file] [--db] [--domain --file : migrate WEBSITE FILES only (docroots / public_html) --db : migrate DATABASES only (MySQL: data + users + grants) (with none of --mail/--file/--db, ALL are migrated) - --domain DOMAIN : narrow to a single domain — its docroot + mailboxes; + --domain DOMAIN : narrow to a single domain, its docroot + mailboxes; composes with --mail/--file (e.g. --domain X --mail). Databases are NOT in --domain scope (--domain --db is rejected: cPanel databases are account-wide) @@ -303,10 +715,16 @@ Usage: %s [--apply|--apply-mirror|--dry-run] [--mail] [--file] [--db] [--domain --verify-checksums : with --apply, compare the exact message-ID set when count+UIDVALIDITY match, before skipping a mailbox --deep-verify : with --apply, verify by CONTENT hash (sha256 per web - file) instead of metadata only — catches same-size + file) instead of metadata only, catches same-size corruption; slower (reads every byte on both sides) + --account-inventory: collect a read-only account inventory (domains, mailboxes, + databases) and exit, no migration is performed --config PATH : path to host.yaml (default: configs/host.yaml) --log-level LEVEL : info (default) or debug (verbose diagnostics to stderr) + --run-id ID : optional run identifier for structured output (default: auto-generated) + --output-dir DIR : output directory for artifacts (logs/, events.jsonl, report.json) + --json-events : write JSONL events to /events.jsonl (one JSON object per line) + --report-json : write JSON summary to /report.json at the end The SOURCE host is ALWAYS read-only; all writes target the DESTINATION. Mail is normally MERGED into the destination (existing messages are kept, only diff --git a/cmd/cpanel-self-migration/main_test.go b/cmd/cpanel-self-migration/main_test.go index af0f9c26..54e25352 100644 --- a/cmd/cpanel-self-migration/main_test.go +++ b/cmd/cpanel-self-migration/main_test.go @@ -19,23 +19,23 @@ func TestValidateScopeFilters(t *testing.T) { }{ // Valid combinations. {"no filters", "", "", false, false, false, ""}, - {"domain bare", "tissolution.it", "", false, false, false, ""}, - {"domain + mail", "tissolution.it", "", true, false, false, ""}, - {"domain + file", "tissolution.it", "", false, true, false, ""}, - {"mailbox", "", "info@tissolution.it", false, false, false, ""}, - {"mailbox + mail (redundant)", "", "info@tissolution.it", true, false, false, ""}, + {"domain bare", "my.example.com", "", false, false, false, ""}, + {"domain + mail", "my.example.com", "", true, false, false, ""}, + {"domain + file", "my.example.com", "", false, true, false, ""}, + {"mailbox", "", "info@my.example.com", false, false, false, ""}, + {"mailbox + mail (redundant)", "", "info@my.example.com", true, false, false, ""}, // Illegal combinations. - {"mailbox + domain", "tissolution.it", "info@tissolution.it", false, false, false, "mutually exclusive"}, - {"mailbox + file", "", "info@tissolution.it", false, true, false, "mail-only"}, - {"mailbox + db", "", "info@tissolution.it", false, false, true, "mail-only"}, - {"domain + db", "tissolution.it", "", false, false, true, "does not support databases"}, + {"mailbox + domain", "my.example.com", "info@my.example.com", false, false, false, "mutually exclusive"}, + {"mailbox + file", "", "info@my.example.com", false, true, false, "mail-only"}, + {"mailbox + db", "", "info@my.example.com", false, false, true, "mail-only"}, + {"domain + db", "my.example.com", "", false, false, true, "does not support databases"}, // Malformed values. {"mailbox no at", "", "noat", false, false, false, "must be local@domain"}, {"mailbox empty domain", "", "info@", false, false, false, "must be local@domain"}, - {"mailbox empty local", "", "@tissolution.it", false, false, false, "must be local@domain"}, - {"mailbox traversal local", "", "..@tissolution.it", false, false, false, "invalid --mailbox"}, + {"mailbox empty local", "", "@my.example.com", false, false, false, "must be local@domain"}, + {"mailbox traversal local", "", "..@my.example.com", false, false, false, "invalid --mailbox"}, {"mailbox bad domain", "", "info@bad/domain", false, false, false, "invalid --mailbox"}, {"domain bad char", "bad/domain", "", false, false, false, "invalid --domain"}, } diff --git a/cmd/cpanel-self-migration/migration_cmd.go b/cmd/cpanel-self-migration/migration_cmd.go new file mode 100644 index 00000000..c307047a --- /dev/null +++ b/cmd/cpanel-self-migration/migration_cmd.go @@ -0,0 +1,337 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/workbench" +) + +const defaultHomeEnv = "CPANEL_MIGRATION_HOME" + +func emitJSON(v interface{}) int { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + fmt.Fprintln(os.Stderr, "error: encode json:", err) + return 1 + } + return 0 +} + +func migrationHome() string { + if v := os.Getenv(defaultHomeEnv); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(".", ".cpanel-self-migration", "migrations") + } + return filepath.Join(home, ".cpanel-self-migration", "migrations") +} + +// runMigrationCmd dispatches `migration ` and returns the exit code. +func runMigrationCmd(args []string) int { + if len(args) == 0 { + migrationUsage() + return 2 + } + switch args[0] { + case "init": + return runMigrationInit(args[1:]) + case "list": + return runMigrationList(args[1:]) + case "show": + return runMigrationShow(args[1:]) + case "set-status": + return runMigrationSetStatus(args[1:]) + case "attach-artifact": + return runMigrationAttachArtifact(args[1:]) + case "archive": + return runMigrationArchive(args[1:]) + default: + migrationUsage() + return 2 + } +} + +func migrationUsage() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration migration ") +} + +func openStore(homeFlag string) (*workbench.Store, error) { + dir := homeFlag + if dir == "" { + dir = migrationHome() + } + return workbench.NewStore(dir) +} + +func runMigrationInit(args []string) int { + fs := flag.NewFlagSet("migration init", flag.ContinueOnError) + name := fs.String("name", "", "migration name/label (required)") + src := fs.String("source-profile", "", "source profile label (required)") + dst := fs.String("destination-profile", "", "destination profile label (required)") + home := fs.String("home", "", "override migrations home directory") + jsonOut := fs.Bool("json", false, "output JSON") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration migration init --name NAME --source-profile SRC --destination-profile DST") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + if *name == "" || *src == "" || *dst == "" { + fmt.Fprintln(os.Stderr, "error: --name, --source-profile, and --destination-profile are required") + return 2 + } + + store, err := openStore(*home) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + sess, err := store.Create(*name, *src, *dst, time.Now().UTC()) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + if *jsonOut { + return emitJSON(sess) + } + fmt.Printf("created session %s (%s)\n", sess.ID, sess.Name) + return 0 +} + +func runMigrationList(args []string) int { + fs := flag.NewFlagSet("migration list", flag.ContinueOnError) + home := fs.String("home", "", "override migrations home directory") + jsonOut := fs.Bool("json", false, "output JSON") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration migration list [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + return 2 + } + + store, err := openStore(*home) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + sessions, warnings, err := store.List() + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + for _, w := range warnings { + fmt.Fprintf(os.Stderr, "warning: %s\n", w) + } + + if *jsonOut { + return emitJSON(sessions) + } + if len(sessions) == 0 { + fmt.Println("no migration sessions") + return 0 + } + for _, sess := range sessions { + fmt.Printf("%-30s %-20s %s -> %s [%s]\n", sess.ID, sess.Name, sess.SourceProfile, sess.DestinationProfile, sess.Status) + } + return 0 +} + +func runMigrationShow(args []string) int { + if len(args) < 1 || strings.HasPrefix(args[0], "-") { + fmt.Fprintln(os.Stderr, "error: session ID required") + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration migration show [--json]") + return 2 + } + id := args[0] + fs := flag.NewFlagSet("migration show", flag.ContinueOnError) + home := fs.String("home", "", "override migrations home directory") + jsonOut := fs.Bool("json", false, "output JSON") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration migration show [--json]") + fs.PrintDefaults() + } + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + + store, err := openStore(*home) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + sess, err := store.Get(id) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + if *jsonOut { + return emitJSON(sess) + } + fmt.Printf("ID: %s\n", sess.ID) + fmt.Printf("Name: %s\n", sess.Name) + fmt.Printf("Source: %s\n", sess.SourceProfile) + fmt.Printf("Destination: %s\n", sess.DestinationProfile) + fmt.Printf("Status: %s\n", sess.Status) + fmt.Printf("Step: %s\n", sess.CurrentStep) + fmt.Printf("Created: %s\n", sess.CreatedAt.Format(time.RFC3339)) + fmt.Printf("Updated: %s\n", sess.UpdatedAt.Format(time.RFC3339)) + if sess.LastError != "" { + fmt.Printf("Last Error: %s\n", sess.LastError) + } + fmt.Printf("Artifacts: %d\n", len(sess.Artifacts)) + fmt.Printf("Timeline: %d events\n", len(sess.Timeline)) + return 0 +} + +func runMigrationSetStatus(args []string) int { + if len(args) < 1 || strings.HasPrefix(args[0], "-") { + fmt.Fprintln(os.Stderr, "error: session ID required") + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration migration set-status --status STATUS [--force --reason REASON]") + return 2 + } + id := args[0] + fs := flag.NewFlagSet("migration set-status", flag.ContinueOnError) + status := fs.String("status", "", "target status (required)") + force := fs.Bool("force", false, "bypass transition matrix (requires --reason)") + reason := fs.String("reason", "", "reason for forced transition") + home := fs.String("home", "", "override migrations home directory") + jsonOut := fs.Bool("json", false, "output JSON") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration migration set-status --status STATUS [--force --reason REASON]") + fs.PrintDefaults() + } + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + if *status == "" { + fmt.Fprintln(os.Stderr, "error: --status is required") + return 2 + } + if !workbench.ValidStatus(workbench.Status(*status)) { + fmt.Fprintf(os.Stderr, "error: unknown status %q\n", *status) + return 2 + } + + store, err := openStore(*home) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + sess, err := store.SetStatus(id, workbench.Status(*status), *force, *reason, time.Now().UTC()) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + if *jsonOut { + return emitJSON(sess) + } + fmt.Printf("%s: status -> %s\n", sess.ID, sess.Status) + return 0 +} + +func runMigrationAttachArtifact(args []string) int { + if len(args) < 1 || strings.HasPrefix(args[0], "-") { + fmt.Fprintln(os.Stderr, "error: session ID required") + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration migration attach-artifact --kind KIND --path PATH") + return 2 + } + id := args[0] + fs := flag.NewFlagSet("migration attach-artifact", flag.ContinueOnError) + kind := fs.String("kind", "", "artifact kind (required)") + path := fs.String("path", "", "path to artifact file (required)") + home := fs.String("home", "", "override migrations home directory") + jsonOut := fs.Bool("json", false, "output JSON") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration migration attach-artifact --kind KIND --path PATH") + fs.PrintDefaults() + } + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + if *kind == "" || *path == "" { + fmt.Fprintln(os.Stderr, "error: --kind and --path are required") + return 2 + } + + if !workbench.ValidArtifactKind(workbench.ArtifactKind(*kind)) { + fmt.Fprintf(os.Stderr, "error: unknown artifact kind %q\n", *kind) + return 2 + } + + store, err := openStore(*home) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + sess, err := store.AttachArtifact(id, workbench.ArtifactKind(*kind), *path, time.Now().UTC()) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + if *jsonOut { + return emitJSON(sess) + } + art := sess.Artifacts[len(sess.Artifacts)-1] + fmt.Printf("attached %s -> %s (sha256:%s)\n", art.Kind, art.Path, art.SHA256[:12]) + return 0 +} + +func runMigrationArchive(args []string) int { + if len(args) < 1 || strings.HasPrefix(args[0], "-") { + fmt.Fprintln(os.Stderr, "error: session ID required") + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration migration archive ") + return 2 + } + id := args[0] + fs := flag.NewFlagSet("migration archive", flag.ContinueOnError) + home := fs.String("home", "", "override migrations home directory") + jsonOut := fs.Bool("json", false, "output JSON") + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: cpanel-self-migration migration archive ") + fs.PrintDefaults() + } + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + + store, err := openStore(*home) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + sess, err := store.SetStatus(id, workbench.StatusArchived, false, "", time.Now().UTC()) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1 + } + + if *jsonOut { + return emitJSON(sess) + } + fmt.Printf("%s: archived\n", sess.ID) + return 0 +} diff --git a/cmd/cpanel-self-migration/migration_cmd_test.go b/cmd/cpanel-self-migration/migration_cmd_test.go new file mode 100644 index 00000000..3e2d74f7 --- /dev/null +++ b/cmd/cpanel-self-migration/migration_cmd_test.go @@ -0,0 +1,234 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/workbench" +) + +// runMigrationChild runs the binary with migration subcommand in an isolated +// home directory and returns exit code, stdout, and stderr. +func runMigrationChild(t *testing.T, homeDir string, args ...string) (int, string, string) { + t.Helper() + fullArgs := append([]string{"migration"}, args...) + cmd := exec.Command(os.Args[0], fullArgs...) + cmd.Dir = t.TempDir() + cmd.Env = append(os.Environ(), + "CPSM_DISPATCH_TEST_CHILD=1", + "CPANEL_MIGRATION_HOME="+homeDir, + ) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + return 0, stdout.String(), stderr.String() + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode(), stdout.String(), stderr.String() + } + t.Fatalf("exec error: %v", err) + return -1, "", "" +} + +func TestDispatchMigrationRefusesUnknownAndBare(t *testing.T) { + home := t.TempDir() + cases := []struct { + name string + args []string + }{ + {"bare migration", nil}, + {"unknown subcommand", []string{"bogus"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, _, stderr := runMigrationChild(t, home, tc.args...) + if code != 2 { + t.Fatalf("exit code = %d, want 2; stderr:\n%s", code, stderr) + } + if !strings.Contains(stderr, "usage: cpanel-self-migration migration") { + t.Errorf("stderr missing usage line:\n%s", stderr) + } + }) + } +} + +func TestMigrationInitAndShow(t *testing.T) { + home := t.TempDir() + + // Init + code, stdout, stderr := runMigrationChild(t, home, "init", + "--name", "testmig", + "--source-profile", "old193", + "--destination-profile", "new78", + "--json") + if code != 0 { + t.Fatalf("init exit %d; stderr:\n%s", code, stderr) + } + + var sess workbench.Session + if err := json.Unmarshal([]byte(stdout), &sess); err != nil { + t.Fatalf("parse init output: %v\nstdout: %s", err, stdout) + } + if sess.Name != "testmig" { + t.Errorf("name = %q", sess.Name) + } + if sess.Status != workbench.StatusDraft { + t.Errorf("status = %q", sess.Status) + } + + // Show + code, stdout, stderr = runMigrationChild(t, home, "show", sess.ID, "--json") + if code != 0 { + t.Fatalf("show exit %d; stderr:\n%s", code, stderr) + } + var shown workbench.Session + if err := json.Unmarshal([]byte(stdout), &shown); err != nil { + t.Fatalf("parse show output: %v", err) + } + if shown.ID != sess.ID { + t.Error("show ID mismatch") + } +} + +func TestMigrationList(t *testing.T) { + home := t.TempDir() + + // Create two sessions + runMigrationChild(t, home, "init", "--name", "a", "--source-profile", "s", "--destination-profile", "d") + runMigrationChild(t, home, "init", "--name", "b", "--source-profile", "s", "--destination-profile", "d") + + code, stdout, stderr := runMigrationChild(t, home, "list", "--json") + if code != 0 { + t.Fatalf("list exit %d; stderr:\n%s", code, stderr) + } + + var sessions []workbench.Session + if err := json.Unmarshal([]byte(stdout), &sessions); err != nil { + t.Fatalf("parse: %v", err) + } + if len(sessions) != 2 { + t.Fatalf("len = %d, want 2", len(sessions)) + } +} + +func TestMigrationSetStatus(t *testing.T) { + home := t.TempDir() + + code, stdout, _ := runMigrationChild(t, home, "init", "--name", "x", + "--source-profile", "s", "--destination-profile", "d", "--json") + if code != 0 { + t.Fatal("init failed") + } + var sess workbench.Session + json.Unmarshal([]byte(stdout), &sess) + + // Valid transition: draft -> preflight_required + code, _, stderr := runMigrationChild(t, home, "set-status", sess.ID, "--status", "preflight_required") + if code != 0 { + t.Fatalf("set-status exit %d; stderr:\n%s", code, stderr) + } + + // Invalid status + code, _, _ = runMigrationChild(t, home, "set-status", sess.ID, "--status", "bogus") + if code != 2 { + t.Errorf("invalid status: exit %d, want 2", code) + } +} + +func TestMigrationSetStatusIllegalTransition(t *testing.T) { + home := t.TempDir() + + code, stdout, _ := runMigrationChild(t, home, "init", "--name", "x", + "--source-profile", "s", "--destination-profile", "d", "--json") + if code != 0 { + t.Fatal("init failed") + } + var sess workbench.Session + json.Unmarshal([]byte(stdout), &sess) + + // draft -> cutover_done is illegal + code, _, stderr := runMigrationChild(t, home, "set-status", sess.ID, "--status", "cutover_done") + if code != 1 { + t.Fatalf("illegal transition: exit %d, want 1; stderr:\n%s", code, stderr) + } +} + +func TestMigrationAttachArtifact(t *testing.T) { + home := t.TempDir() + + code, stdout, _ := runMigrationChild(t, home, "init", "--name", "x", + "--source-profile", "s", "--destination-profile", "d", "--json") + if code != 0 { + t.Fatal("init failed") + } + var sess workbench.Session + json.Unmarshal([]byte(stdout), &sess) + + // Create a temp artifact + artFile := filepath.Join(t.TempDir(), "plan.json") + os.WriteFile(artFile, []byte(`{"plan":"ok"}`), 0644) + + code, _, stderr := runMigrationChild(t, home, "attach-artifact", sess.ID, + "--kind", "dns_plan", "--path", artFile) + if code != 0 { + t.Fatalf("attach exit %d; stderr:\n%s", code, stderr) + } + + // Unknown kind -> exit 2 + code, _, _ = runMigrationChild(t, home, "attach-artifact", sess.ID, + "--kind", "host_yaml", "--path", artFile) + if code != 2 { + t.Errorf("unknown kind: exit %d, want 2", code) + } +} + +func TestMigrationArchive(t *testing.T) { + home := t.TempDir() + + code, stdout, _ := runMigrationChild(t, home, "init", "--name", "x", + "--source-profile", "s", "--destination-profile", "d", "--json") + if code != 0 { + t.Fatal("init failed") + } + var sess workbench.Session + json.Unmarshal([]byte(stdout), &sess) + + // Draft cannot directly archive (terminal only from cutover_done/blocked/failed) + code, _, _ = runMigrationChild(t, home, "archive", sess.ID) + if code != 1 { + t.Errorf("archive from draft: exit %d, want 1", code) + } + + // Force to blocked, then archive + runMigrationChild(t, home, "set-status", sess.ID, + "--status", "blocked", "--force", "--reason", "external dependency blocks progress") + code, _, stderr := runMigrationChild(t, home, "archive", sess.ID) + if code != 0 { + t.Fatalf("archive from blocked: exit %d; stderr:\n%s", code, stderr) + } +} + +func TestMigrationShowMissing(t *testing.T) { + home := t.TempDir() + code, _, _ := runMigrationChild(t, home, "show", "nonexistent") + if code != 1 { + t.Errorf("missing session: exit %d, want 1", code) + } +} + +func TestMigrationInitMissingFlags(t *testing.T) { + home := t.TempDir() + code, _, stderr := runMigrationChild(t, home, "init", "--name", "x") + if code != 2 { + t.Fatalf("missing flags: exit %d, want 2; stderr:\n%s", code, stderr) + } +} diff --git a/docs/COMMAND.md b/docs/COMMAND.md index c32b35e8..e9a1cb5f 100644 --- a/docs/COMMAND.md +++ b/docs/COMMAND.md @@ -66,11 +66,11 @@ make build ./cpanel-self-migration --apply --db --deep-verify # databases: row counts + table checksum # NARROW to one domain or one mailbox -./cpanel-self-migration --domain tissolution.it # dry-run: that domain's docroot + mail -./cpanel-self-migration --apply --domain tissolution.it # apply: that domain's docroot + mail (no DB) -./cpanel-self-migration --apply --domain tissolution.it --mail # only that domain's mailboxes -./cpanel-self-migration --apply --domain tissolution.it --file # only that domain's docroot -./cpanel-self-migration --apply --mailbox info@tissolution.it # only that one mailbox (copy + verify) +./cpanel-self-migration --domain my.example.com # dry-run: that domain's docroot + mail +./cpanel-self-migration --apply --domain my.example.com # apply: that domain's docroot + mail (no DB) +./cpanel-self-migration --apply --domain my.example.com --mail # only that domain's mailboxes +./cpanel-self-migration --apply --domain my.example.com --file # only that domain's docroot +./cpanel-self-migration --apply --mailbox info@my.example.com # only that one mailbox (copy + verify) # Custom config / debug ./cpanel-self-migration --config /path/host.yaml diff --git a/docs/USAGE.md b/docs/USAGE.md index 6b0d41b8..f031108a 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -188,10 +188,10 @@ end-to-end onto a fresh account. Examples: ```sh -./cpanel-self-migration --domain tissolution.it # dry-run: docroot + mail for one domain -./cpanel-self-migration --apply --domain tissolution.it # apply: docroot + mail (no DB) -./cpanel-self-migration --apply --domain tissolution.it --mail # only that domain's mailboxes -./cpanel-self-migration --apply --mailbox info@tissolution.it # only one mailbox (copy + verify) +./cpanel-self-migration --domain my.example.com # dry-run: docroot + mail for one domain +./cpanel-self-migration --apply --domain my.example.com # apply: docroot + mail (no DB) +./cpanel-self-migration --apply --domain my.example.com --mail # only that domain's mailboxes +./cpanel-self-migration --apply --mailbox info@my.example.com # only one mailbox (copy + verify) ``` --- diff --git a/go.mod b/go.mod index fe26b23f..54532186 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/tis24dev/cPanel_self-migration go 1.25.12 require ( - golang.org/x/crypto v0.53.0 + golang.org/x/crypto v0.54.0 gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/sys v0.46.0 // indirect +require golang.org/x/sys v0.47.0 // indirect diff --git a/go.sum b/go.sum index 26b3cee1..a70b5311 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/accountinventory/acceptance_merge.go b/internal/accountinventory/acceptance_merge.go new file mode 100644 index 00000000..8948572e --- /dev/null +++ b/internal/accountinventory/acceptance_merge.go @@ -0,0 +1,40 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +// MergeAcceptance upserts one operator acceptance into an acceptance file, +// binding the whole file to the checklist the operator was looking at. It +// is the write-side counterpart of the CLI's acceptance loader (PR 7D) and +// is used by the browser accept flow (UI 2b). +// +// - existing may be nil (a fresh file); its prior entries are preserved +// in order. +// - an entry with the same ActionKey is UPDATED in place (reason/author/ +// date), never duplicated - re-accepting an action revises it. +// - the whole file is (re)stamped with the CURRENT checklistFile / +// checklistSHA256, so the strict hash check in loadAcceptancesFile +// keeps matching after the checklist is regenerated with these +// acceptances applied. +// +// A caller MUST pass a non-empty acc.ActionKey; an empty key would coalesce +// with any other empty-key entry via the first-match upsert. +func MergeAcceptance(existing *AcceptanceFile, checklistFile, checklistSHA256 string, acc OperatorAcceptance) AcceptanceFile { + out := AcceptanceFile{ + Mode: AcceptanceFileMode, + FormatVersion: 1, + ChecklistFile: checklistFile, + ChecklistSHA256: checklistSHA256, + } + if existing != nil { + out.Acceptances = append(out.Acceptances, existing.Acceptances...) + } + for i := range out.Acceptances { + if out.Acceptances[i].ActionKey == acc.ActionKey { + out.Acceptances[i] = acc + return out + } + } + out.Acceptances = append(out.Acceptances, acc) + return out +} diff --git a/internal/accountinventory/acceptance_merge_test.go b/internal/accountinventory/acceptance_merge_test.go new file mode 100644 index 00000000..6862855d --- /dev/null +++ b/internal/accountinventory/acceptance_merge_test.go @@ -0,0 +1,75 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import "testing" + +func acc(key, reason, by string) OperatorAcceptance { + return OperatorAcceptance{ActionKey: key, Reason: reason, AcceptedBy: by, AcceptedAt: "2026-07-02T10:00:00Z"} +} + +func TestMergeAcceptanceInsert(t *testing.T) { + got := MergeAcceptance(nil, "migration_checklist.json", "abc", acc("AK-1", "r1", "reviewer")) + if got.Mode != AcceptanceFileMode || got.FormatVersion != 1 { + t.Errorf("mode/version = %q/%d", got.Mode, got.FormatVersion) + } + if got.ChecklistFile != "migration_checklist.json" || got.ChecklistSHA256 != "abc" { + t.Errorf("checklist binding = %q/%q", got.ChecklistFile, got.ChecklistSHA256) + } + if len(got.Acceptances) != 1 || got.Acceptances[0].ActionKey != "AK-1" { + t.Fatalf("acceptances = %+v, want the inserted entry", got.Acceptances) + } +} + +func TestMergeAcceptanceUpsertByKey(t *testing.T) { + base := MergeAcceptance(nil, "migration_checklist.json", "abc", acc("AK-1", "old", "reviewer")) + base = MergeAcceptance(&base, "migration_checklist.json", "abc", acc("AK-2", "r2", "reviewer")) + // Re-accept AK-1 with a new reason/author -> UPDATE in place, not a dup. + got := MergeAcceptance(&base, "migration_checklist.json", "abc", acc("AK-1", "new reason", "luca")) + + if len(got.Acceptances) != 2 { + t.Fatalf("acceptances = %d, want 2 (upsert, not duplicate)", len(got.Acceptances)) + } + var a1 *OperatorAcceptance + for i := range got.Acceptances { + if got.Acceptances[i].ActionKey == "AK-1" { + a1 = &got.Acceptances[i] + } + } + if a1 == nil || a1.Reason != "new reason" || a1.AcceptedBy != "luca" { + t.Errorf("AK-1 = %+v, want the updated reason/author", a1) + } + // Order is stable: AK-1 keeps its original position (first). + if got.Acceptances[0].ActionKey != "AK-1" || got.Acceptances[1].ActionKey != "AK-2" { + t.Errorf("order = %s,%s, want AK-1,AK-2 preserved", got.Acceptances[0].ActionKey, got.Acceptances[1].ActionKey) + } +} + +func TestMergeAcceptanceRestampsSHA(t *testing.T) { + base := MergeAcceptance(nil, "migration_checklist.json", "sha-X", acc("AK-1", "r1", "reviewer")) + // A later accept, after the checklist was regenerated (new sha), must + // re-stamp the WHOLE file to the current sha so the strict hash check + // keeps matching on the next regeneration. + got := MergeAcceptance(&base, "migration_checklist.json", "sha-Y", acc("AK-2", "r2", "reviewer")) + if got.ChecklistSHA256 != "sha-Y" { + t.Errorf("sha = %q, want the current sha-Y", got.ChecklistSHA256) + } +} + +func TestMergeAcceptancePreservesExistingWhenUpsertingDifferentKey(t *testing.T) { + base := AcceptanceFile{ + Mode: AcceptanceFileMode, FormatVersion: 1, + ChecklistFile: "migration_checklist.json", ChecklistSHA256: "old", + Acceptances: []OperatorAcceptance{acc("AK-1", "r1", "a"), acc("AK-2", "r2", "b")}, + } + got := MergeAcceptance(&base, "migration_checklist.json", "new", acc("AK-3", "r3", "c")) + if len(got.Acceptances) != 3 { + t.Fatalf("acceptances = %d, want 3 (two preserved + one added)", len(got.Acceptances)) + } + for i, k := range []string{"AK-1", "AK-2", "AK-3"} { + if got.Acceptances[i].ActionKey != k { + t.Errorf("pos %d = %s, want %s", i, got.Acceptances[i].ActionKey, k) + } + } +} diff --git a/internal/accountinventory/batch/batch_test.go b/internal/accountinventory/batch/batch_test.go new file mode 100644 index 00000000..68cdba05 --- /dev/null +++ b/internal/accountinventory/batch/batch_test.go @@ -0,0 +1,311 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package batch_test + +// Equivalence proof for the prefetch/replay batch shim: the batched collector +// output must be BYTE-IDENTICAL to the sequential collector (both drive the same +// stub uapi/cpapi2/crontab over the real sshx transport / in-process SSH server). +// The stub dispatches on argv `$1 $2` after `shift`, so it answers the batched +// server-side loops identically to the sequential per-call invocations. + +import ( + "bytes" + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory" + "github.com/tis24dev/cPanel_self-migration/internal/accountinventory/batch" + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "github.com/tis24dev/cPanel_self-migration/internal/sshtest" + "github.com/tis24dev/cPanel_self-migration/internal/sshx" +) + +const plantedSecret = "SmokeSecretXYZ123" +const plantedRawCommand = "/usr/local/bin/backup.sh --password=" + plantedSecret + " | gzip > /tmp/b.gz" +const stubCrontab = `MAILTO=ops@example.test +# nightly backup +0 3 * * * ` + plantedRawCommand + ` +@daily /usr/bin/php /home/u/cron.php +#30 2 * * 0 /bin/weekly.sh +` + +// setupStubs mirrors the cmd-level contract harness: stub uapi/cpapi2/crontab +// that dispatch on `$1 $2` after dropping --output=json. +func setupStubs(t *testing.T, dnsUAPIFails, noCrontab bool) { + t.Helper() + tmp := t.TempDir() + stubDir := filepath.Join(tmp, "bin") + fixDir := filepath.Join(tmp, "fixtures") + for _, d := range []string{stubDir, fixDir} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + for _, name := range []string{ + "domaininfo_list.json", "domaininfo_domains_data.json", + "email_list_pops.json", "email_forwarders.json", "email_autoresponders.json", + "ftp_list.json", "ssl_list_certs.json", "php_vhost_versions.json", + "dns_parse_zone.json", "dns_fetchzone_records.json", + "email_list_mxs_realserver.json", "email_default_address_realserver.json", + "email_list_filters.json", "mime_redirects_realserver.json", + } { + b, err := os.ReadFile(filepath.Join("..", "..", "..", "internal", "testdata", name)) + if err != nil { + t.Fatalf("fixture %s: %v", name, err) + } + if err := os.WriteFile(filepath.Join(fixDir, name), b, 0o600); err != nil { + t.Fatal(err) + } + } + writeFix := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(fixDir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + writeFix("mysql_list_databases.json", + `{"result":{"status":1,"data":[{"database":"u_wp","disk_usage":2048,"users":["u_admin"]}]}}`) + writeFix("dns_parse_zone_fail.json", + `{"result":{"status":0,"data":null,"errors":["Failed to load module DNS"]}}`) + writeFix("crontab_sample.txt", stubCrontab) + + writeStub := func(name, body string) { + t.Helper() + path := filepath.Join(stubDir, name) + if err := os.WriteFile(path, []byte("#!/bin/bash\n"+body), 0o755); err != nil { // #nosec G306 -- stub must be executable + t.Fatal(err) + } + } + dnsFixture := "dns_parse_zone.json" + if dnsUAPIFails { + dnsFixture = "dns_parse_zone_fail.json" + } + writeStub("uapi", `shift # drop --output=json +case "$1 $2" in + "DomainInfo list_domains") cat "$CPSM_TEST_FIXDIR/domaininfo_list.json" ;; + "DomainInfo domains_data") cat "$CPSM_TEST_FIXDIR/domaininfo_domains_data.json" ;; + "Email list_pops_with_disk") cat "$CPSM_TEST_FIXDIR/email_list_pops.json" ;; + "Email list_forwarders") cat "$CPSM_TEST_FIXDIR/email_forwarders.json" ;; + "Email list_auto_responders") cat "$CPSM_TEST_FIXDIR/email_autoresponders.json" ;; + "Mysql list_databases") cat "$CPSM_TEST_FIXDIR/mysql_list_databases.json" ;; + "Ftp list_ftp_with_disk") cat "$CPSM_TEST_FIXDIR/ftp_list.json" ;; + "SSL list_certs") cat "$CPSM_TEST_FIXDIR/ssl_list_certs.json" ;; + "LangPHP php_get_vhost_versions") cat "$CPSM_TEST_FIXDIR/php_vhost_versions.json" ;; + "DNS parse_zone") cat "$CPSM_TEST_FIXDIR/`+dnsFixture+`" ;; + "Email list_mxs") cat "$CPSM_TEST_FIXDIR/email_list_mxs_realserver.json" ;; + "Email list_default_address") cat "$CPSM_TEST_FIXDIR/email_default_address_realserver.json" ;; + "Email list_filters") cat "$CPSM_TEST_FIXDIR/email_list_filters.json" ;; + "Mime list_redirects") cat "$CPSM_TEST_FIXDIR/mime_redirects_realserver.json" ;; + *) echo '{"result":{"status":0,"errors":["stub: unknown uapi call"]}}' ;; +esac`) + writeStub("cpapi2", `shift # drop --output=json +case "$1 $2" in + "ZoneEdit fetchzone_records") cat "$CPSM_TEST_FIXDIR/dns_fetchzone_records.json" ;; + *) echo '{"cpanelresult":{"data":[],"event":{"result":0},"error":"stub: unknown cpapi2 call"}}' ;; +esac`) + if noCrontab { + writeStub("crontab", `if [ "$1" = "-l" ]; then echo "no crontab for u" >&2; exit 1; fi; exit 1`) + } else { + writeStub("crontab", `if [ "$1" = "-l" ]; then cat "$CPSM_TEST_FIXDIR/crontab_sample.txt"; exit 0; fi; exit 1`) + } + t.Setenv("CPSM_TEST_FIXDIR", fixDir) + t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HOME", tmp) +} + +func dialStub(t *testing.T) *sshx.Client { + t.Helper() + home := t.TempDir() + addr := sshtest.NewExecServer(t, home) + if _, _, err := net.SplitHostPort(addr); err != nil { + t.Fatal(err) + } + return sshtest.DialExec(t, addr) +} + +var collectedAtRE = regexp.MustCompile(`"collected_at": "[^"]*"`) + +func normalize(b []byte) []byte { + return collectedAtRE.ReplaceAll(b, []byte(`"collected_at": "SENTINEL"`)) +} + +func marshalSide(t *testing.T, inv accountinventory.NormalizedInventory) []byte { + t.Helper() + b, err := json.MarshalIndent(inv, "", " ") + if err != nil { + t.Fatal(err) + } + return normalize(b) +} + +// TestBatchEquivalence: sequential vs batched collector, byte-identical output, +// plus a request-key coverage assertion (no silent SSH fallback). +func TestBatchEquivalence(t *testing.T) { + sshtest.RequireTools(t, "bash", "cat") + setupStubs(t, false, false) + ctx := context.Background() + info := accountinventory.HostInfo{User: "u", Host: "127.0.0.1"} + + // Sequential golden. + seqClient := dialStub(t) + defer seqClient.Close() + seqRes, err := accountinventory.Collect(ctx, seqClient, nil, info, accountinventory.HostInfo{}) + if err != nil { + t.Fatalf("sequential collect: %v", err) + } + golden := marshalSide(t, seqRes.Source) + + // Batched via the prefetch shim, counting fallback calls through a wrapper. + batClient := dialStub(t) + defer batClient.Close() + base := &countingRunner{base: batClient} + rr, err := batch.Prefetch(ctx, batClient, base) + if err != nil { + t.Fatalf("prefetch: %v", err) + } + base.armed = true // only count fallbacks made DURING collection + batRes, err := accountinventory.Collect(ctx, rr, nil, info, accountinventory.HostInfo{}) + if err != nil { + t.Fatalf("batched collect: %v", err) + } + got := marshalSide(t, batRes.Source) + + if !bytes.Equal(golden, got) { + dumpDiff(t, golden, got) + t.Fatal("batched inventory is not byte-identical to the sequential golden") + } + if base.count != 0 { + t.Errorf("collection made %d fallback SSH call(s); every request should be served from the prefetch table", base.count) + } +} + +// TestBatchEquivalenceFallbacks: DNS UAPI fails (API2 fallback) and no crontab. +func TestBatchEquivalenceFallbacks(t *testing.T) { + sshtest.RequireTools(t, "bash", "cat") + setupStubs(t, true, true) + ctx := context.Background() + info := accountinventory.HostInfo{User: "u", Host: "127.0.0.1"} + + seqClient := dialStub(t) + defer seqClient.Close() + seqRes, err := accountinventory.Collect(ctx, seqClient, nil, info, accountinventory.HostInfo{}) + if err != nil { + t.Fatalf("sequential collect: %v", err) + } + golden := marshalSide(t, seqRes.Source) + + batClient := dialStub(t) + defer batClient.Close() + base := &countingRunner{base: batClient} + rr, err := batch.Prefetch(ctx, batClient, base) + if err != nil { + t.Fatalf("prefetch: %v", err) + } + base.armed = true + batRes, err := accountinventory.Collect(ctx, rr, nil, info, accountinventory.HostInfo{}) + if err != nil { + t.Fatalf("batched collect: %v", err) + } + got := marshalSide(t, batRes.Source) + if !bytes.Equal(golden, got) { + dumpDiff(t, golden, got) + t.Fatal("batched inventory (fallback matrix) is not byte-identical to sequential") + } + if base.count != 0 { + t.Errorf("fallback matrix made %d unexpected SSH call(s)", base.count) + } + + // Confirm the DNS API2 fallback and empty-crontab shapes came through. + var inv map[string]any + if err := json.Unmarshal(got, &inv); err != nil { + t.Fatal(err) + } + dns := inv["dns"].(map[string]any) + if dns["method"] != "api2" { + t.Errorf("dns method = %v, want api2", dns["method"]) + } + cron := inv["cron"].(map[string]any) + if cron["available"] != true { + t.Errorf("empty crontab must stay available, got %v", cron["available"]) + } + if jobs := cron["jobs"].([]any); len(jobs) != 0 { + t.Errorf("jobs = %d, want 0", len(jobs)) + } +} + +// TestBatchSecretOracle: the planted cron secret and its raw-command sha256 never +// reach the batched artifact. +func TestBatchSecretOracle(t *testing.T) { + sshtest.RequireTools(t, "bash", "cat") + setupStubs(t, false, false) + ctx := context.Background() + info := accountinventory.HostInfo{User: "u", Host: "127.0.0.1"} + c := dialStub(t) + defer c.Close() + rr, err := batch.Prefetch(ctx, c, c) + if err != nil { + t.Fatalf("prefetch: %v", err) + } + res, err := accountinventory.Collect(ctx, rr, nil, info, accountinventory.HostInfo{}) + if err != nil { + t.Fatalf("collect: %v", err) + } + b := marshalSide(t, res.Source) + s := string(b) + if strings.Contains(s, plantedSecret) { + t.Error("batched artifact leaks the planted secret") + } + for _, k := range []string{`"command_clear"`, `"raw_line"`, `"value_clear"`} { + if strings.Contains(s, k) { + t.Errorf("batched artifact serializes raw field %s (must be json:\"-\")", k) + } + } + // Cron must still be collected (3 jobs) via the verbatim crontab payload. + var inv map[string]any + if err := json.Unmarshal(b, &inv); err != nil { + t.Fatal(err) + } + jobs := inv["cron"].(map[string]any)["jobs"].([]any) + if len(jobs) != 3 { + t.Errorf("cron jobs = %d, want 3", len(jobs)) + } +} + +// countingRunner counts RunScript calls once armed (fallbacks during collection). +type countingRunner struct { + base cpanel.Runner + armed bool + count int +} + +func (r *countingRunner) RunScript(ctx context.Context, script string, env map[string]string) ([]byte, error) { + if r.armed { + r.count++ + } + return r.base.RunScript(ctx, script, env) +} + +// dumpDiff logs the first differing line for a quick read. +func dumpDiff(t *testing.T, want, got []byte) { + t.Helper() + wl := strings.Split(string(want), "\n") + gl := strings.Split(string(got), "\n") + n := len(wl) + if len(gl) < n { + n = len(gl) + } + for i := 0; i < n; i++ { + if wl[i] != gl[i] { + t.Logf("first diff at line %d:\n want: %s\n got: %s", i+1, wl[i], gl[i]) + return + } + } + t.Logf("line counts differ: want %d, got %d", len(wl), len(gl)) +} diff --git a/internal/accountinventory/batch/frame.go b/internal/accountinventory/batch/frame.go new file mode 100644 index 00000000..fae07157 --- /dev/null +++ b/internal/accountinventory/batch/frame.go @@ -0,0 +1,310 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// Package batch prefetches the read-only account inventory in a small, constant +// number of batched SSH execs and replays the responses to the UNMODIFIED +// accountinventory collector via a cpanel.Runner shim. +// +// Why a shim (and zero collector change): the collector, every cpanel decoder, +// every in-function sort, and the entire cron redaction pipeline already produce +// the canonical NormalizedInventory. Reproducing any of that server-side (or in a +// second Go decode path) risks a byte-level drift in the artifact. So this +// package changes only WHERE Runner.RunScript sources its bytes: from a table +// pre-populated by the batched execs, keyed by requestKey(script, env). A call +// the plan did not anticipate misses the table and falls through to one real SSH +// call (never wrong, only not batched). +// +// EXEC COUNT (3 per side, not 2). The theoretical 2-exec minimum is unreachable +// without server-side JSON parsing: the get_auto_responder addresses and the +// get_filter (name, account) pairs are only known AFTER decoding the Pass-2 +// list_auto_responders / list_filters bodies. We refuse the alternative of a +// server-side `grep -oE`/`jq` field extraction: it would reintroduce a compact- +// JSON field-order assumption (the exact fragility the design avoids) and would +// need a new dependency / a server-side parser. The three passes are: +// +// Pass 1 : the 12 uapi singles + crontab, framed by exit status. +// Pass 2 : per-domain/zone/mailbox loops (forwarders, auto_responders, +// parse_zone [+ fetchzone_records fallback], list_filters). +// Pass 2b : per-address/per-filter loops (get_auto_responder, get_filter). +// +// TOLERATED VARIANCE: on a FAILING section the free-text `%v` tail of the +// "...unavailable: %v" warning is server-dependent (it echoes the remote +// stderr/exit shape). Every structural field of the artifact is byte-identical +// to the sequential collector; only that one warning tail may differ, and only +// when a section actually fails. +// +// The wire protocol below (parseFrame / hdrFields / validateKeys) is the +// robustness core: it can drop a malformed/odd/empty record but can never +// mis-associate one record's bytes with another record's key, and never panics. +package batch + +import ( + "strings" + + "github.com/tis24dev/cPanel_self-migration/internal/validate" +) + +// Frame tags. Each is a bash-safe literal emitted verbatim by the pass scripts. +const ( + tagDomains = "DOMAINS" // DomainInfo::list_domains + tagDocroots = "DOCROOTS" // DomainInfo::domains_data + tagPops = "POPS" // Email::list_pops_with_disk + tagDBs = "DBS" // Mysql::list_databases + tagFTP = "FTP" // Ftp::list_ftp_with_disk + tagSSL = "SSL" // SSL::list_certs + tagPHP = "PHP" // LangPHP::php_get_vhost_versions + tagMXs = "MXS" // Email::list_mxs + tagDefAddr = "DEFADDR" // Email::list_default_address + tagFiltersAcct = "FILTERS_ACCT" // Email::list_filters (account level, no arg) + tagRedirects = "REDIRECTS" // Mime::list_redirects + tagCron = "CRON" // crontab -l (verbatim, marker in payload) + tagForwarders = "FWD" // Email::list_forwarders [domain] + tagAutorespList = "AR" // Email::list_auto_responders [domain] + tagAutorespDet = "ARDET" // Email::get_auto_responder [address] + tagDNSUAPI = "DNSU" // DNS::parse_zone [zone] + tagDNSAPI2 = "DNS2" // ZoneEdit::fetchzone_records [zone] + tagFiltMbx = "FILT" // Email::list_filters [mailbox] + tagGetFilter = "GFILT" // Email::get_filter [account, idx] +) + +// --------------------------------------------------------------------------- +// Server-side pass scripts (fed to `bash -s` over StreamNul). +// +// Discipline shared by all three: +// - `set -u` (an unset var is a bug, fail loud rather than silently mis-loop). +// - emit/emitkey frame OK/ERR by EXIT STATUS and a NON-EMPTY capture only. They +// NEVER interpret the JSON `status` field: a body with `status:0` is framed OK +// so the UNCHANGED cpanel.parseUAPI produces the identical `status=N errors=...` +// error string the sequential collector would. The frame ERR path is reserved +// for a transport/exec failure (uapi/cpapi2 missing or non-zero, empty output). +// - stderr is bounded via `head -c 200` from a mktemp file (never the payload). +// - payload is emitted with `printf '...\000'` and a `%s` conversion, so a +// payload byte is NEVER interpreted as a printf directive. +// - every loop guards `[ -n "$x" ] || continue` so a trailing blank list line +// (empty env list) does not spawn a bogus call. +// +// The record shape is: TAG \t STATUS [\t key ...] \t payload \000 +// --------------------------------------------------------------------------- + +// framePrelude defines the emit helpers reused by every pass. `emit` frames a +// no-key record; `emitk` a one-key record; `emitk2` a two-key record. Each runs +// its command, captures stdout, and frames OK (exit 0 AND non-empty) else ERR +// with a bounded stderr head. +const framePrelude = `set -u +__err=$(mktemp 2>/dev/null || echo /dev/null) +emit() { # $1=TAG ; runs "$@" from $2 on + local tag="$1"; shift + local out; out=$("$@" 2>"$__err"); local rc=$? + if [ $rc -eq 0 ] && [ -n "$out" ]; then + printf '%s\tOK\t%s\000' "$tag" "$out" + else + printf '%s\tERR\t%s\000' "$tag" "$(head -c 200 "$__err" 2>/dev/null)" + fi +} +emitk() { # $1=TAG $2=KEY ; runs "$@" from $3 on + local tag="$1"; local key="$2"; shift 2 + local out; out=$("$@" 2>"$__err"); local rc=$? + if [ $rc -eq 0 ] && [ -n "$out" ]; then + printf '%s\tOK\t%s\t%s\000' "$tag" "$key" "$out" + else + printf '%s\tERR\t%s\t%s\000' "$tag" "$key" "$(head -c 200 "$__err" 2>/dev/null)" + fi +} +emitk2() { # $1=TAG $2=KEY $3=KEY2 ; runs "$@" from $4 on + local tag="$1"; local key="$2"; local key2="$3"; shift 3 + local out; out=$("$@" 2>"$__err"); local rc=$? + if [ $rc -eq 0 ] && [ -n "$out" ]; then + printf '%s\tOK\t%s\t%s\t%s\000' "$tag" "$key" "$key2" "$out" + else + printf '%s\tERR\t%s\t%s\t%s\000' "$tag" "$key" "$key2" "$(head -c 200 "$__err" 2>/dev/null)" + fi +} +` + +// pass1Script: the 12 no-arg uapi singles + verbatim crontab. The crontab record +// runs the SAME crontabScript body as cpanel.FetchCrontab and is always framed OK +// (the script exits 0; the real rc rides the __CRONTAB_RC marker in the payload). +const pass1Script = framePrelude + ` +emit DOMAINS uapi --output=json DomainInfo list_domains +emit DOCROOTS uapi --output=json DomainInfo domains_data +emit POPS uapi --output=json Email list_pops_with_disk +emit DBS uapi --output=json Mysql list_databases +emit FTP uapi --output=json Ftp list_ftp_with_disk +emit SSL uapi --output=json SSL list_certs +emit PHP uapi --output=json LangPHP php_get_vhost_versions +emit MXS uapi --output=json Email list_mxs +emit DEFADDR uapi --output=json Email list_default_address +emit FILTERS_ACCT uapi --output=json Email list_filters +emit REDIRECTS uapi --output=json Mime list_redirects +__cron=$(crontab -l 2>&1); __crc=$? +printf 'CRON\tOK\t%s\n__CRONTAB_RC:%d__\n\000' "$__cron" "$__crc" +` + +// pass2Script: per-domain forwarders + auto_responders, per-zone parse_zone with +// an in-loop fetchzone_records fallback (DNS2 emitted only when DNSU failed, so +// the collector's fallback is prefetched), per-mailbox list_filters. Lists arrive +// as newline-joined env (DOMAINS_NL/ZONES_NL/MBX_NL). +const pass2Script = framePrelude + ` +while IFS= read -r d; do + [ -n "$d" ] || continue + emitk FWD "$d" uapi --output=json Email list_forwarders domain="$d" + emitk AR "$d" uapi --output=json Email list_auto_responders domain="$d" +done <<< "${DOMAINS_NL:-}" +while IFS= read -r z; do + [ -n "$z" ] || continue + emitk DNSU "$z" uapi --output=json DNS parse_zone zone="$z" + # DNS2 (fetchzone_records) is prefetched for EVERY zone, not gated on the + # DNSU exit status: a UAPI parse_zone can exit 0 yet return a JSON status:0 + # body that the collector's parseUAPI rejects, driving its API2 fallback. We + # cannot see that JSON-level failure server-side without parsing, so we always + # plant the fallback. When DNSU satisfied the collector, this DNS2 entry is + # simply never requested (a harmless dead table row). + emitk DNS2 "$z" cpapi2 --output=json ZoneEdit fetchzone_records domain="$z" +done <<< "${ZONES_NL:-}" +while IFS= read -r m; do + [ -n "$m" ] || continue + emitk FILT "$m" uapi --output=json Email list_filters account="$m" +done <<< "${MBX_NL:-}" +` + +// pass2bScript: per-address get_auto_responder (ARADDR_NL) and per-filter +// get_filter (GFILT_NL, one "accountidxfiltername" line per filter). +// The frame key is account+idx (idx keys uniquely even with duplicate names); the +// filtername is passed only as the uapi `filtername=` argv value. +const pass2bScript = framePrelude + ` +while IFS= read -r a; do + [ -n "$a" ] || continue + emitk ARDET "$a" uapi --output=json Email get_auto_responder email="$a" +done <<< "${ARADDR_NL:-}" +while IFS=$'\x1f' read -r acct idx fname; do + [ -n "$idx" ] || continue + if [ -n "$acct" ]; then + emitk2 GFILT "$acct" "$idx" uapi --output=json Email get_filter filtername="$fname" account="$acct" + else + emitk2 GFILT "$acct" "$idx" uapi --output=json Email get_filter filtername="$fname" + fi +done <<< "${GFILT_NL:-}" +` + +// hdrFields is the number of key fields between STATUS and the payload for each +// tag. The batched (looped) tags carry the loop key; the once-per-account tags +// carry none. +// +// CRON carries ZERO key fields on purpose: the pass runs crontabScript verbatim, +// so the `__CRONTAB_RC:__` marker rides INSIDE the payload and FetchCrontab's +// own splitCronMarker parses it. Lifting rc to the header would re-implement cron +// logic this design deliberately leaves untouched. +var hdrFields = map[string]int{ + tagDomains: 0, tagDocroots: 0, tagPops: 0, tagDBs: 0, tagFTP: 0, + tagSSL: 0, tagPHP: 0, tagMXs: 0, tagDefAddr: 0, tagFiltersAcct: 0, + tagRedirects: 0, tagCron: 0, + tagForwarders: 1, tagAutorespList: 1, tagAutorespDet: 1, + tagDNSUAPI: 1, tagDNSAPI2: 1, tagFiltMbx: 1, tagGetFilter: 2, +} + +// maxFramePayload caps an OK payload as belt-and-suspenders; StreamNul already +// caps a single record at 1 MiB, so this only fires on a pathological frame. +const maxFramePayload = 8 << 20 + +// frame is one decoded, key-validated wire record. +type frame struct { + tag string + status string // "OK" | "ERR" + keys []string + payload string +} + +// parseFrame decodes one NUL-stripped record into a frame. It returns ok=false +// (drop) on any structural anomaly and NEVER panics: an unknown tag, a missing +// TAB, too few key fields, a bad STATUS, or a key that fails validateKeys all +// drop the record rather than risk indexing a Go map with a corrupt key (which +// would mis-associate that payload with another request). +func parseFrame(record string) (frame, bool) { + // 1. tag + tab := strings.IndexByte(record, '\t') + if tab < 0 { + return frame{}, false + } + tag := record[:tab] + nKeys, known := hdrFields[tag] + if !known { + return frame{}, false + } + rest := record[tab+1:] + + // 2. STATUS + nKeys keys + payload, cap so payload TABs (CRON) stay whole. + parts := strings.SplitN(rest, "\t", nKeys+2) + if len(parts) < nKeys+1 { // need at least STATUS + all key fields + return frame{}, false + } + status := parts[0] + if status != "OK" && status != "ERR" { + return frame{}, false + } + keys := parts[1 : 1+nKeys] + + var payload string + if len(parts) == nKeys+2 { + payload = parts[nKeys+1] + } + // An OK frame with no payload field is malformed (a real response is never + // empty); an ERR frame may legitimately carry an empty body. + if status == "OK" && len(parts) != nKeys+2 { + return frame{}, false + } + if len(payload) > maxFramePayload { + return frame{}, false + } + + // 3. re-validate every key (second wall against a corrupted stream). + if !validateKeys(tag, keys) { + return frame{}, false + } + return frame{tag: tag, status: status, keys: keys, payload: payload}, true +} + +// validateKeys re-checks the header keys of a batched frame with the SAME rules +// the emission side used, so a corrupted key can never reach the replay table. +func validateKeys(tag string, keys []string) bool { + switch tag { + case tagForwarders, tagAutorespList, tagFiltMbx: + // FWD/AR keys are domains; FILT keys are mailbox addresses. Both are + // validated as an address-or-domain (mailbox splits at the last '@'). + return validAddrOrDomain(keys[0]) + case tagAutorespDet: + // ARDET key is a full email address. + return validAddrOrDomain(keys[0]) + case tagDNSUAPI, tagDNSAPI2: + return validate.Domain(keys[0]) == nil + case tagGetFilter: + // account (may be "" for the account-level scope) + numeric idx. + if keys[0] != "" && !validAddrOrDomain(keys[0]) { + return false + } + return isASCIIDigits(keys[1]) + default: + return true + } +} + +// validAddrOrDomain accepts a bare domain or a mailbox address. For an address it +// splits at the LAST '@' and validates the local part and the domain separately. +func validAddrOrDomain(s string) bool { + at := strings.LastIndexByte(s, '@') + if at < 0 { + return validate.Domain(s) == nil + } + return validate.MailboxUser(s[:at]) == nil && validate.Domain(s[at+1:]) == nil +} + +func isASCIIDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/internal/accountinventory/batch/frame_test.go b/internal/accountinventory/batch/frame_test.go new file mode 100644 index 00000000..cbed598a --- /dev/null +++ b/internal/accountinventory/batch/frame_test.go @@ -0,0 +1,117 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package batch + +import "testing" + +func TestParseFrameOK(t *testing.T) { + rec := "DOMAINS\tOK\t{\"result\":{\"status\":1}}" + f, ok := parseFrame(rec) + if !ok { + t.Fatal("expected ok") + } + if f.tag != "DOMAINS" || f.status != "OK" || f.payload != `{"result":{"status":1}}` { + t.Fatalf("bad frame: %+v", f) + } + if len(f.keys) != 0 { + t.Fatalf("DOMAINS must have 0 keys, got %v", f.keys) + } +} + +func TestParseFrameKeyedPayloadKeepsTabs(t *testing.T) { + // A CRON-style payload with embedded tabs and newlines must stay whole: the + // capped SplitN stops after STATUS+keys, so payload TABs are not split. + payload := "line1\tafter-tab\nline2\n__CRONTAB_RC:0__\n" + rec := "CRON\tOK\t" + payload + f, ok := parseFrame(rec) + if !ok { + t.Fatal("expected ok") + } + if f.payload != payload { + t.Fatalf("payload mangled:\n want %q\n got %q", payload, f.payload) + } +} + +func TestParseFrameSingleKey(t *testing.T) { + rec := "FWD\tOK\texample.test\t[]" + f, ok := parseFrame(rec) + if !ok { + t.Fatal("expected ok") + } + if len(f.keys) != 1 || f.keys[0] != "example.test" || f.payload != "[]" { + t.Fatalf("bad keyed frame: %+v", f) + } +} + +func TestParseFrameTwoKeys(t *testing.T) { + rec := "GFILT\tOK\tuser@example.test\t2\t{}" + f, ok := parseFrame(rec) + if !ok { + t.Fatal("expected ok") + } + if len(f.keys) != 2 || f.keys[0] != "user@example.test" || f.keys[1] != "2" { + t.Fatalf("bad two-key frame: %+v", f) + } +} + +func TestParseFrameDrops(t *testing.T) { + cases := []struct { + name string + rec string + }{ + {"no tab", "DOMAINSOK"}, + {"unknown tag", "BOGUS\tOK\tx"}, + {"bad status", "DOMAINS\tMAYBE\tx"}, + {"ok missing payload", "DOMAINS\tOK"}, + {"fwd too few fields", "FWD\tOK"}, + {"gfilt too few fields", "GFILT\tOK\tacct"}, + {"fwd key fails validate.Domain", "FWD\tOK\tbad domain\t[]"}, + {"dnsu key illegal char", "DNSU\tOK\tex$ample\t{}"}, + {"gfilt non-digit idx", "GFILT\tOK\tacct@x.test\tNaN\t{}"}, + } + for _, tc := range cases { + if _, ok := parseFrame(tc.rec); ok { + t.Errorf("%s: expected drop, got ok", tc.name) + } + } +} + +func TestParseFrameErrEmptyBodyAllowed(t *testing.T) { + if _, ok := parseFrame("DOMAINS\tERR\t"); !ok { + t.Error("ERR with empty body should parse") + } + if _, ok := parseFrame("FWD\tERR\texample.test\t"); !ok { + t.Error("keyed ERR with empty body should parse") + } +} + +func TestParseFrameGFiltAccountLevel(t *testing.T) { + // account-level filter has an empty account key; idx must still validate. + if _, ok := parseFrame("GFILT\tOK\t\t0\t{}"); !ok { + t.Error("account-level GFILT (empty account) should parse") + } +} + +// TestCrontabScriptMatchesCpanel guards the duplicated crontabScript against a +// drift from cpanel.crontabScript (the collector's FetchCrontab uses that exact +// body, so a divergence would produce a table miss / a different payload). +func TestCrontabScriptMatchesCpanel(t *testing.T) { + const want = `out=$(crontab -l 2>&1); rc=$?; printf '%s\n__CRONTAB_RC:%d__\n' "$out" "$rc"` + if crontabScript != want { + t.Fatalf("crontabScript drifted from cpanel.crontabScript:\n want %q\n got %q", want, crontabScript) + } +} + +func TestRequestKeyIncludesEnv(t *testing.T) { + s := `uapi --output=json Email list_forwarders domain="$ARG_0"` + k1 := requestKey(s, map[string]string{"ARG_0": "a.test"}) + k2 := requestKey(s, map[string]string{"ARG_0": "b.test"}) + if k1 == k2 { + t.Fatal("request key must differ when env differs (per-domain calls share a script)") + } + // Stable regardless of map construction order. + if requestKey(s, map[string]string{"ARG_0": "a.test"}) != k1 { + t.Fatal("request key must be stable") + } +} diff --git a/internal/accountinventory/batch/key.go b/internal/accountinventory/batch/key.go new file mode 100644 index 00000000..a79b1a1a --- /dev/null +++ b/internal/accountinventory/batch/key.go @@ -0,0 +1,41 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package batch + +import ( + "sort" + "strings" +) + +// requestKey is the canonical serialization of a Runner.RunScript call +// (script + env), used as the replay-table key. +// +// It MUST include env, not just the script: the collector's per-domain and +// per-mailbox uapi calls (list_forwarders, list_auto_responders, get_filter, +// ...) all emit the SAME script body from uapiArgsScript and differ ONLY in the +// ARG_i environment values. Keying on the script alone would collapse every +// per-domain call onto one another. env is serialized in sorted-key order so +// the key is stable regardless of Go map iteration order. +// +// The separators (\x1f between fields, \x1e between env entries) are ASCII unit/ +// record separators that cannot appear in a bash script literal we build or in +// an ARG_i value we splice (values are pre-validated / percent-encoded), so the +// serialization is unambiguous. +func requestKey(script string, env map[string]string) string { + var b strings.Builder + b.WriteString(script) + b.WriteByte(0x1f) + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(env[k]) + b.WriteByte(0x1e) + } + return b.String() +} diff --git a/internal/accountinventory/batch/prefetch.go b/internal/accountinventory/batch/prefetch.go new file mode 100644 index 00000000..b53f5c83 --- /dev/null +++ b/internal/accountinventory/batch/prefetch.go @@ -0,0 +1,402 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package batch + +import ( + "bytes" + "context" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "github.com/tis24dev/cPanel_self-migration/internal/logx" + "github.com/tis24dev/cPanel_self-migration/internal/sshx" + "github.com/tis24dev/cPanel_self-migration/internal/validate" +) + +// crontabScript is the byte-for-byte copy of cpanel.crontabScript. It runs +// verbatim in Pass 1 so FetchCrontab's own splitCronMarker classifies the rc; +// this package never re-implements cron parsing. It is duplicated (not exported +// from cpanel) to keep the total cpanel change at the single EncodeUAPIArgValue +// export; a drift is caught by TestCrontabScriptMatchesCpanel. +const crontabScript = `out=$(crontab -l 2>&1); rc=$?; printf '%s\n__CRONTAB_RC:%d__\n' "$out" "$rc"` + +// Prefetch runs the three batched passes against c and returns a cpanel.Runner +// that replays them to the unmodified collector. base is the fallback runner for +// any unanticipated call (normally c itself). On a pass-level transport failure +// it returns the error so the caller falls back to the direct client. +func Prefetch(ctx context.Context, c *sshx.Client, base cpanel.Runner) (cpanel.Runner, error) { + rr := newReplayRunner(base) + if err := runPass1(ctx, c, rr); err != nil { + return nil, err + } + domains, zones, mailboxes, err := derivePass2Inputs(ctx, rr) + if err != nil { + return nil, err + } + if err := runPass2(ctx, c, rr, domains, zones, mailboxes); err != nil { + return nil, err + } + addrs, filters, err := derivePass2bInputs(ctx, rr, domains, mailboxes) + if err != nil { + return nil, err + } + if err := runPass2b(ctx, c, rr, addrs, filters); err != nil { + return nil, err + } + return rr, nil +} + +// --- request-key reconstruction (mirrors cpanel.uapiArgsScript / api2ArgsScript) + +// uapiScript reproduces cpanel.uapiArgsScript byte-for-byte: the same command +// prefix, the same sorted-key order, the same ARG_i env, and the same +// EncodeUAPIArgValue on each value. It is the single source of the request keys +// this package plants, so a replay key always matches what the collector emits. +func uapiScript(module, fn string, args map[string]string) (string, map[string]string) { + env := map[string]string{} + var b strings.Builder + b.WriteString("uapi --output=json ") + b.WriteString(module) + b.WriteString(" ") + b.WriteString(fn) + keys := sortedKeys(args) + for i, k := range keys { + ev := fmt.Sprintf("ARG_%d", i) + env[ev] = cpanel.EncodeUAPIArgValue(args[k]) + fmt.Fprintf(&b, " %s=\"$%s\"", k, ev) + } + return b.String(), env +} + +// api2Script reproduces cpanel.api2ArgsScript byte-for-byte (no value encoding, +// matching that path). +func api2Script(module, fn string, args map[string]string) (string, map[string]string) { + env := map[string]string{} + var b strings.Builder + b.WriteString("cpapi2 --output=json ") + b.WriteString(module) + b.WriteString(" ") + b.WriteString(fn) + keys := sortedKeys(args) + for i, k := range keys { + ev := fmt.Sprintf("ARG_%d", i) + env[ev] = args[k] + fmt.Fprintf(&b, " %s=\"$%s\"", k, ev) + } + return b.String(), env +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// --- Pass 1 ----------------------------------------------------------------- + +// uapiSingle names one no-arg uapi call collected in Pass 1. +type uapiSingle struct { + tag string + module string + fn string +} + +var pass1Singles = []uapiSingle{ + {tagDomains, "DomainInfo", "list_domains"}, + {tagDocroots, "DomainInfo", "domains_data"}, + {tagPops, "Email", "list_pops_with_disk"}, + {tagDBs, "Mysql", "list_databases"}, + {tagFTP, "Ftp", "list_ftp_with_disk"}, + {tagSSL, "SSL", "list_certs"}, + {tagPHP, "LangPHP", "php_get_vhost_versions"}, + {tagMXs, "Email", "list_mxs"}, + {tagDefAddr, "Email", "list_default_address"}, + {tagFiltersAcct, "Email", "list_filters"}, + {tagRedirects, "Mime", "list_redirects"}, +} + +// runPass1 executes the 12 no-arg uapi singles plus the verbatim crontab in one +// exec, then plants each response (or synthesized ERR) under its request key. +func runPass1(ctx context.Context, c *sshx.Client, rr *replayRunner) error { + frames, err := runFramed(ctx, c, pass1Script) + if err != nil { + return fmt.Errorf("inventory prefetch pass 1: %w", err) + } + for _, s := range pass1Singles { + script, env := uapiScript(s.module, s.fn, nil) + if f, ok := frames[frameSlot{tag: s.tag}]; ok { + plant(rr, script, env, f) + } else { + rr.putMissing(script, env, s.tag) + } + } + if f, ok := frames[frameSlot{tag: tagCron}]; ok && f.status == "OK" { + rr.put(crontabScript, nil, []byte(f.payload)) + } else { + // crontabScript always exits 0 (rc rides the marker), so a CRON ERR + // frame is a transport anomaly; a real fallback re-runs crontab. + rr.putMissing(crontabScript, nil, tagCron) + } + return nil +} + +// --- Pass 2 ----------------------------------------------------------------- + +// derivePass2Inputs decodes the Pass-1 domain and mailbox lists via the +// collector's OWN decoders (against the partial table), then validates every +// domain / zone / mailbox before it can enter a Pass-2 env list. A rejected name +// is skipped (a warning is logged); its later collector call misses the table +// and is served by one real SSH call, so it is never dropped. +func derivePass2Inputs(ctx context.Context, rr *replayRunner) (domains, zones, mailboxes []string, err error) { + ds, err := cpanel.ListDomains(ctx, rr) + if err != nil { + return nil, nil, nil, fmt.Errorf("inventory prefetch: decode domains: %w", err) + } + seenZone := map[string]bool{} + for _, d := range ds { + if validate.Domain(d.Name) != nil { + logx.Warn("inventory prefetch: skipping invalid domain %q from batch (collected via direct call)", d.Name) + continue + } + domains = append(domains, d.Name) + // Zones mirror collectDNS: skip sub domains, dedup. + if d.Type.String() == "sub" { + continue + } + if !seenZone[d.Name] { + seenZone[d.Name] = true + zones = append(zones, d.Name) + } + } + // Mailbox filters mirror collectEmailFilters: only real "@" addresses. + accts, aerr := cpanel.ListEmailAccounts(ctx, rr) + if aerr == nil { + for _, a := range accts { + if !strings.Contains(a.Email, "@") { + continue + } + if !validAddrOrDomain(a.Email) { + logx.Warn("inventory prefetch: skipping invalid mailbox %q from batch (collected via direct call)", a.Email) + continue + } + mailboxes = append(mailboxes, a.Email) + } + } + // If the pops list itself failed, the collector marks mailboxes unavailable + // and skips per-mailbox filters, so an empty mailbox list here is correct. + return domains, zones, mailboxes, nil +} + +// runPass2 loops forwarders + auto_responders (per domain), parse_zone +// [+ fetchzone_records fallback] (per zone), and list_filters (per mailbox) in +// one exec, delivering the validated lists as newline-joined env. +func runPass2(ctx context.Context, c *sshx.Client, rr *replayRunner, domains, zones, mailboxes []string) error { + env := map[string]string{ + "DOMAINS_NL": strings.Join(domains, "\n"), + "ZONES_NL": strings.Join(zones, "\n"), + "MBX_NL": strings.Join(mailboxes, "\n"), + } + frames, err := runFramedEnv(ctx, c, pass2Script, env) + if err != nil { + return fmt.Errorf("inventory prefetch pass 2: %w", err) + } + for _, d := range domains { + fScript, fEnv := uapiScript("Email", "list_forwarders", map[string]string{"domain": d}) + plantKeyed(rr, frames, tagForwarders, d, fScript, fEnv, "forwarders "+d) + + aScript, aEnv := uapiScript("Email", "list_auto_responders", map[string]string{"domain": d}) + plantKeyed(rr, frames, tagAutorespList, d, aScript, aEnv, "auto_responders "+d) + } + for _, z := range zones { + // Both the UAPI parse_zone and the API2 fetchzone_records are planted. + // The collector calls parse_zone first; if its parseUAPI rejects the body + // (e.g. a status:0 error), it falls back to fetchzone_records, which is + // already in the table. Whichever it does not need is a dead table row. + uScript, uEnv := uapiScript("DNS", "parse_zone", map[string]string{"zone": z}) + plantKeyed(rr, frames, tagDNSUAPI, z, uScript, uEnv, "parse_zone "+z) + + a2Script, a2Env := api2Script("ZoneEdit", "fetchzone_records", map[string]string{"domain": z}) + plantKeyed(rr, frames, tagDNSAPI2, z, a2Script, a2Env, "fetchzone_records "+z) + } + for _, mb := range mailboxes { + fScript, fEnv := uapiScript("Email", "list_filters", map[string]string{"account": mb}) + plantKeyed(rr, frames, tagFiltMbx, mb, fScript, fEnv, "list_filters "+mb) + } + return nil +} + +// --- Pass 2b ---------------------------------------------------------------- + +// filterRef is one (account, idx, filtername) triple for a get_filter call. idx +// is the 0-based position in the account's list_filters result, so duplicate +// filter names still key uniquely. +type filterRef struct { + account string + idx int + filterName string +} + +// derivePass2bInputs decodes AR lists and filter lists from the table (the +// collector's own decoders) to derive the get_auto_responder addresses and the +// get_filter (account, idx, name) triples. Address completion mirrors the +// collector's `addr = a.Email (+ "@"+d)` rule exactly. +func derivePass2bInputs(ctx context.Context, rr *replayRunner, domains, mailboxes []string) (addrs []string, filters []filterRef, err error) { + for _, d := range domains { + ars, e := cpanel.ListAutoresponders(ctx, rr, d) + if e != nil { + continue // collector will warn; no ARDET to prefetch for this domain + } + for _, a := range ars { + addr := a.Email + if !strings.Contains(addr, "@") { + addr = addr + "@" + d + } + if validAddrOrDomain(addr) { + addrs = append(addrs, addr) + } + } + } + // Account-level filters first (account=""), then per real mailbox, matching + // collectEmailFilters' order. idx is the position in EACH list_filters call. + acctScopes := append([]string{""}, mailboxes...) + for _, acct := range acctScopes { + list, e := cpanel.ListEmailFilters(ctx, rr, acct) + if e != nil { + continue + } + for i, f := range list { + // The name goes only into an argv VALUE (pre-encoded), never a frame + // key; still reject framing-hostile bytes before use. + if strings.ContainsAny(f.FilterName, "\t\n\x00") { + continue + } + filters = append(filters, filterRef{account: acct, idx: i, filterName: f.FilterName}) + } + } + return addrs, filters, nil +} + +// runPass2b loops get_auto_responder (per address) and get_filter (per +// account/idx) in one exec. Addresses and accounts are pre-encoded with +// EncodeUAPIArgValue (uapi path); the filter name is delivered as an argv value +// keyed by idx. +func runPass2b(ctx context.Context, c *sshx.Client, rr *replayRunner, addrs []string, filters []filterRef) error { + // Build the get_filter env payload: one line "accountidxfiltername" + // per filter, using the ASCII unit separator (0x1f) between fields. A + // NON-whitespace IFS is required so an EMPTY leading account field (the + // account-level scope) is preserved by `read` rather than collapsed (TAB, an + // IFS-whitespace char, would drop it and shift idx into account). Values are + // the RAW forms; the request key is reconstructed via uapiScript, which + // applies the uapi encoding independently, so it always matches the collector. + var gf strings.Builder + for _, fr := range filters { + gf.WriteString(fr.account) + gf.WriteByte(0x1f) + gf.WriteString(strconv.Itoa(fr.idx)) + gf.WriteByte(0x1f) + gf.WriteString(fr.filterName) + gf.WriteByte('\n') + } + env := map[string]string{ + "ARADDR_NL": strings.Join(addrs, "\n"), + "GFILT_NL": strings.TrimRight(gf.String(), "\n"), + } + frames, err := runFramedEnv(ctx, c, pass2bScript, env) + if err != nil { + return fmt.Errorf("inventory prefetch pass 2b: %w", err) + } + for _, addr := range addrs { + script, aenv := uapiScript("Email", "get_auto_responder", map[string]string{"email": addr}) + plantKeyed(rr, frames, tagAutorespDet, addr, script, aenv, "get_auto_responder "+addr) + } + for _, fr := range filters { + args := map[string]string{"filtername": fr.filterName} + if fr.account != "" { + args["account"] = fr.account + } + script, fenv := uapiScript("Email", "get_filter", args) + slot := frameSlot{tag: tagGetFilter, key: fr.account, key2: strconv.Itoa(fr.idx)} + desc := fmt.Sprintf("get_filter %q (account=%q)", fr.filterName, fr.account) + if f, ok := frames[slot]; ok { + plant(rr, script, fenv, f) + } else { + rr.putMissing(script, fenv, desc) + } + } + return nil +} + +// --- framing plumbing ------------------------------------------------------- + +// frameSlot indexes a decoded frame by tag and up to two keys. The keys are the +// ENCODED values (matching what the pass script echoes back), so a lookup lines +// up with the request key the collector will produce. +type frameSlot struct { + tag string + key string + key2 string +} + +// runFramed / runFramedEnv stream a pass script over StreamNul and collect the +// decoded frames into a slot map. A frame that fails parseFrame is dropped +// (never mis-associated); a duplicate slot keeps the first (deterministic). +func runFramed(ctx context.Context, c *sshx.Client, script string) (map[frameSlot]frame, error) { + return runFramedEnv(ctx, c, script, nil) +} + +func runFramedEnv(ctx context.Context, c *sshx.Client, script string, env map[string]string) (map[frameSlot]frame, error) { + cmd := "bash -s" + if len(env) > 0 { + cmd = sshx.WithEnv(cmd, env) + } + out := map[frameSlot]frame{} + err := c.StreamNul(ctx, cmd, bytes.NewReader([]byte(script)), func(rec string) error { + f, ok := parseFrame(rec) + if !ok { + return nil // drop malformed/odd record, never panic or mis-associate + } + slot := frameSlot{tag: f.tag} + if len(f.keys) >= 1 { + slot.key = f.keys[0] + } + if len(f.keys) >= 2 { + slot.key2 = f.keys[1] + } + if _, dup := out[slot]; !dup { + out[slot] = f + } + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// plant stores a frame's OK payload verbatim, or a synthesized ERR that fires the +// collector's fail-soft branch. +func plant(rr *replayRunner, script string, env map[string]string, f frame) { + if f.status == "OK" { + rr.put(script, env, []byte(f.payload)) + } else { + rr.putErr(script, env, f.payload) + } +} + +// plantKeyed looks up a single-key batched frame (keyed by the RAW loop value +// the pass script echoes back) and plants it, synthesizing a missing-record ERR +// when the stream omitted it. +func plantKeyed(rr *replayRunner, frames map[frameSlot]frame, tag, key, script string, env map[string]string, desc string) { + if f, ok := frames[frameSlot{tag: tag, key: key}]; ok { + plant(rr, script, env, f) + return + } + rr.putMissing(script, env, desc) +} diff --git a/internal/accountinventory/batch/runner.go b/internal/accountinventory/batch/runner.go new file mode 100644 index 00000000..2cdd364d --- /dev/null +++ b/internal/accountinventory/batch/runner.go @@ -0,0 +1,84 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package batch + +import ( + "context" + "fmt" + "strings" + + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" +) + +// replayEntry is one canned RunScript result: verbatim server bytes, or a +// synthesized error whose SHAPE matches a real `bash -s` failure so the +// collector's fail-soft branch fires identically. +type replayEntry struct { + out []byte + err error +} + +// replayRunner serves Runner.RunScript from a prefetched table, falling back to +// the real client for any call the batch did not anticipate. It carries no +// concurrency: the collector calls it strictly sequentially. +type replayRunner struct { + base cpanel.Runner + table map[string]replayEntry +} + +func newReplayRunner(base cpanel.Runner) *replayRunner { + return &replayRunner{base: base, table: map[string]replayEntry{}} +} + +// RunScript returns the prefetched response for (script, env) or, on a miss, +// makes one real SSH call. A miss is always CORRECT (identical bytes, one extra +// round-trip): it happens only when a name failed validation and was skipped +// from a batch, or when the plan could not anticipate a call. +func (rr *replayRunner) RunScript(ctx context.Context, script string, env map[string]string) ([]byte, error) { + if e, ok := rr.table[requestKey(script, env)]; ok { + return e.out, e.err + } + return rr.base.RunScript(ctx, script, env) +} + +// put stores a verbatim-OK response under its request key. +func (rr *replayRunner) put(script string, env map[string]string, out []byte) { + rr.table[requestKey(script, env)] = replayEntry{out: append([]byte(nil), out...)} +} + +// putErr stores a synthesized failure under its request key. The message mirrors +// the `run` failure shape ("bash -s ... failed: ") so the wrapping +// error chain (runUAPIExec's "%s::%s: %w", then the section's "...unavailable: +// %v") is byte-identical apart from the tolerated server-dependent %v tail. +func (rr *replayRunner) putErr(script string, env map[string]string, errBody string) { + rr.table[requestKey(script, env)] = replayEntry{ + err: fmt.Errorf("bash -s failed: %s", clip(strings.TrimSpace(errBody), 200)), + } +} + +// putMissing stores a synthesized ERR for a request the plan EXPECTED but the +// stream did not deliver (a dropped/odd frame). This keeps a missing record on +// the "...unavailable: %v" degradation path instead of letting it slip to a +// silent real-SSH fallback that would mask the failure and defeat O(1). +func (rr *replayRunner) putMissing(script string, env map[string]string, desc string) { + if _, ok := rr.table[requestKey(script, env)]; ok { + return + } + rr.table[requestKey(script, env)] = replayEntry{ + err: fmt.Errorf("bash -s failed: no response for %s", desc), + } +} + +// clip truncates s to at most n bytes on a rune boundary, matching the bounded +// stderr shape the transport produces. +func clip(s string, n int) string { + if len(s) <= n { + return s + } + r := []rune(s) + if len(r) > n { + r = r[:n] + } + return string(r) +} diff --git a/internal/accountinventory/blocker_scope_test.go b/internal/accountinventory/blocker_scope_test.go new file mode 100644 index 00000000..bd7d9a24 --- /dev/null +++ b/internal/accountinventory/blocker_scope_test.go @@ -0,0 +1,89 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import "testing" + +func TestBlockerScopingNSChangedIsCutoverOnly(t *testing.T) { + diff := InventoryDiff{ + Sections: map[string]SectionDiff{ + "dns": { + Changed: []DiffFieldChange{{ + Key: "zone example.com NS example.com.", Field: "records", + Source: "ns1.old.com.", Destination: "ns1.new.com.", + }}, + }, + }, + } + policy := EvaluatePolicy(diff) + cl := BuildChecklist(ChecklistInput{ + Source: NormalizedInventory{Account: AccountInfo{User: "example"}}, + Destination: NormalizedInventory{Account: AccountInfo{User: "example"}}, + Diff: diff, + Policy: policy, + }) + + if cl.OverallStatus != OverallBlocked { + t.Errorf("overall_status = %q, want BLOCKED", cl.OverallStatus) + } + if cl.ApplyBlocked { + t.Error("apply_blocked should be false - POL-DNS-NS-CHANGED is cutover-only") + } + + found := false + for _, s := range cl.Sections { + if s.Section == "dns" { + if len(s.BlockersApply) > 0 { + t.Errorf("dns section has BlockersApply = %v, want empty", s.BlockersApply) + } + if len(s.BlockersCutover) == 0 { + t.Error("dns section has no BlockersCutover, want POL-DNS-NS-CHANGED") + } + found = true + } + } + if !found { + t.Error("dns section not found in checklist") + } +} + +func TestBlockerScopingDomainMainRemovedIsApply(t *testing.T) { + diff := InventoryDiff{ + Sections: map[string]SectionDiff{ + "domains": { + Removed: []DiffEntry{{Key: "example.com", Detail: "main"}}, + }, + }, + } + policy := EvaluatePolicy(diff) + cl := BuildChecklist(ChecklistInput{ + Source: NormalizedInventory{Account: AccountInfo{User: "example"}}, + Destination: NormalizedInventory{Account: AccountInfo{User: "example"}}, + Diff: diff, + Policy: policy, + }) + + if !cl.ApplyBlocked { + t.Error("apply_blocked should be true - POL-DOMAIN-MAIN-REMOVED blocks apply") + } + + found := false + for _, s := range cl.Sections { + if s.Section == "domains" { + if len(s.BlockersApply) == 0 { + t.Error("domains section has no BlockersApply, want POL-DOMAIN-MAIN-REMOVED") + } + found = true + } + } + if !found { + t.Error("domains section not found in checklist") + } +} + +func TestBlockerScopingDefaultConservative(t *testing.T) { + if blockerScopeCutover["POL-NONEXISTENT-RULE"] { + t.Error("unknown rule should NOT be in cutover scope (default conservative = apply)") + } +} diff --git a/internal/accountinventory/checklist.go b/internal/accountinventory/checklist.go new file mode 100644 index 00000000..b9107106 --- /dev/null +++ b/internal/accountinventory/checklist.go @@ -0,0 +1,1172 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +// BuildChecklist composes the migration checklist from already-produced +// artifacts (inventories, diff, policy report, optional DNS plan, optional +// migration report). Pure and deterministic: same input, same output - +// GeneratedAt and the input file references are the caller's concern. +// +// Honesty rules, in order of importance: +// - migrated_by_tool is NEVER true without evidence. A missing migration +// report means "unknown", even when both sides look identical. +// - a DNS plan proves a difference is expected ONLY when the destination +// already matches the desired translation (action=skip). Pending plan +// work (add/replace) is still work, not an expected difference. +// - areas the inventory cannot see are reported as their own sections +// (not_inventoried / not_accessible_without_root), never silently ok. + +import ( + "crypto/sha256" + "fmt" + "sort" + "strings" +) + +// checklistSectionOrder fixes the section order in the output: the 10 +// inventoried sections (diff order, with the synthetic web_files after +// domains), then the not-inventoried areas, then the root-only ones. +var checklistSectionOrder = []string{ + "domains", "web_files", "mailboxes", "databases", "forwarders", + "autoresponders", "ftp", "ssl", "php", "dns", "cron", + "email_routing", "default_address", "email_filters", "redirects", + "quota_package", "server_level_config", +} + +// blockerScopeCutover lists blocker-severity policy rules that gate ONLY +// the cutover (DNS switch), NOT the pre-cutover config apply. Any blocker +// rule NOT in this set defaults to "apply" scope (conservative). +var blockerScopeCutover = map[string]bool{ + "POL-DNS-NS-CHANGED": true, + "POL-DNS-NS-REMOVED": true, + "POL-DNS-MX-CHANGED": true, + "POL-DNS-MX-REMOVED": true, + "POL-MAILBOX-REMOVED": true, + "POL-DB-REMOVED": true, + "POL-SSL-REMOVED": true, + "POL-CRON-ENABLED-REMOVED": true, +} + +type checklistBuilder struct { + in ChecklistInput + warnings []string + + // evidence/migrated per section, from the migration report. + evidence map[string]string + migrated map[string]bool + + // findings grouped by section (order preserved from the policy report, + // which is already deterministically sorted). + findings map[string][]PolicyFinding + + // planOps indexes the DNS plan ops by zone/type/canonical-name. + planOps map[string]PlanOp + + actions []ManualAction + sectionActions map[string][]int // section -> indexes into actions + + // Operator acceptances (PR 7D), indexed by stable action key. + // acceptMatched tracks which keys actually matched an action, so the + // unmatched ones can warn (stale acceptances self-invalidate loudly). + acceptByKey map[string]OperatorAcceptance + acceptMatched map[string]bool + + // chainMismatch is set when the provenance verification finds a + // PROVEN hash mismatch (not a mere absence): the composition is + // inconsistent and a READY_* verdict must be capped. + chainMismatch bool +} + +// BuildChecklist is the engine entry point. +func BuildChecklist(in ChecklistInput) MigrationChecklist { + b := &checklistBuilder{ + in: in, + warnings: []string{}, + evidence: map[string]string{}, + migrated: map[string]bool{}, + findings: map[string][]PolicyFinding{}, + planOps: map[string]PlanOp{}, + sectionActions: map[string][]int{}, + acceptByKey: map[string]OperatorAcceptance{}, + acceptMatched: map[string]bool{}, + } + for _, acc := range in.Acceptances { + if _, dup := b.acceptByKey[acc.ActionKey]; dup { + b.warnings = append(b.warnings, fmt.Sprintf( + "acceptances: duplicate entry for action key %s - first entry wins", acc.ActionKey)) + continue + } + b.acceptByKey[acc.ActionKey] = acc + } + for _, f := range in.Policy.Findings { + b.findings[f.Section] = append(b.findings[f.Section], f) + } + if in.DNSPlan != nil { + for _, z := range in.DNSPlan.Zones { + for _, op := range z.Ops { + b.planOps[planOpKey(z.Zone, op.Type, op.Name)] = op + } + } + } + b.computeEvidence() + + chainVerified, chainWarnings, chainMismatch := verifyProvenanceChain(in) + b.warnings = append(b.warnings, chainWarnings...) + b.chainMismatch = chainMismatch + + c := MigrationChecklist{ + Mode: "migration-checklist", + FormatVersion: 1, + Account: in.Source.Account.User, + Inputs: in.InputRefs, + ChainVerified: chainVerified, + Sections: []ChecklistSection{}, + ManualActions: []ManualAction{}, + Warnings: []string{}, + CoverageManifest: CoverageAreas(), + } + for _, name := range checklistSectionOrder { + c.Sections = append(c.Sections, b.buildSection(name)) + } + + // Assign stable IDs in generation order (section order is fixed and + // per-section generation follows already-sorted findings/diff entries), + // then cross-reference them into the owning sections. + for i := range b.actions { + b.actions[i].ID = fmt.Sprintf("MA-%03d", i+1) + } + c.ManualActions = append(c.ManualActions, b.actions...) + for i := range c.Sections { + refs := []string{} + accepted := []string{} + for _, idx := range b.sectionActions[c.Sections[i].Section] { + refs = append(refs, b.actions[idx].ID) + if b.actions[idx].Accepted { + accepted = append(accepted, b.actions[idx].ID) + } + } + c.Sections[i].ManualActionRefs = refs + c.Sections[i].AcceptedByOperator = accepted + } + // Every acceptance must have found its action: an unmatched key means + // the underlying fact changed (or never existed) since the operator + // reviewed it - say so instead of silently dropping the acceptance. + unmatched := []string{} + for key := range b.acceptByKey { + if !b.acceptMatched[key] { + unmatched = append(unmatched, key) + } + } + sort.Strings(unmatched) + for _, key := range unmatched { + b.warnings = append(b.warnings, fmt.Sprintf( + "acceptances: no current action matches key %s - the underlying finding changed since it was reviewed; re-review", key)) + } + + c.Warnings = append(c.Warnings, b.warnings...) + b.summarize(&c) + return c +} + +// --------------------------------------------------------------------------- +// Migration evidence +// --------------------------------------------------------------------------- + +// computeEvidence maps the optional migration report to per-section +// evidence. Only a SUCCESSFUL apply run counts; everything else is "none" +// plus a warning explaining why the report was ignored. +func (b *checklistBuilder) computeEvidence() { + for _, name := range checklistSectionOrder { + b.evidence[name] = EvidenceNone + } + rep := b.in.MigrationReport + if rep == nil { + return + } + if rep.Mode != "apply" { + b.warnings = append(b.warnings, fmt.Sprintf( + "migration report %q is not an apply run (mode %q) - ignored as migration evidence", rep.RunID, rep.Mode)) + return + } + if rep.ExitStatus != "success" { + b.warnings = append(b.warnings, fmt.Sprintf( + "migration report %q did not succeed (exit_status %q) - ignored as migration evidence", rep.RunID, rep.ExitStatus)) + return + } + // PR 7C upgrade: when the (successful) report's phases_completed proves + // BOTH the migrate and the verify phase of a section's flow completed, + // evidence rises to per_item - the verify phases are per-item integrity + // passes whose failures make the run non-success, so "success + both + // phases" proves each item was individually processed and verified. + // Domains have no verify phase: creation is itself per-item and its + // failures gate the exit status the same way. + completed := map[string]bool{} + for _, p := range rep.PhasesCompleted { + completed[p] = true + } + perItem := map[string]bool{ + "mailboxes": completed["migrate_mail"] && completed["verify_mail"], + "web_files": completed["copy_files"] && completed["verify_files"], + "databases": completed["migrate_db"] && completed["verify_db"], + "domains": completed["create_domains"], + } + mark := func(section string) { + b.evidence[section] = EvidenceRunLevel + if perItem[section] { + b.evidence[section] = EvidencePerItem + } + b.migrated[section] = true + } + if rep.Scope.Mail { + mark("mailboxes") + } + if rep.Scope.Files { + mark("web_files") + } + if rep.Scope.Databases { + mark("databases") + } + // Domain creation runs as part of every apply flow. + if rep.Scope.Mail || rep.Scope.Files || rep.Scope.Databases { + mark("domains") + } +} + +// --------------------------------------------------------------------------- +// Section builders +// --------------------------------------------------------------------------- + +func (b *checklistBuilder) buildSection(name string) ChecklistSection { + switch name { + case "web_files": + return b.buildWebFilesSection() + case "quota_package", "server_level_config": + return b.buildRootOnlySection(name) + default: + return b.buildInventoriedSection(name) + } +} + +func newChecklistSection(name string) ChecklistSection { + return ChecklistSection{ + Section: name, + MigrationEvidence: EvidenceNone, + ExpectedDifferences: []ExpectedDifference{}, + ManualActionRefs: []string{}, + Blockers: []string{}, + BlockersApply: []string{}, + BlockersCutover: []string{}, + PolicyFindingRefs: []string{}, + AcceptedByOperator: []string{}, + PostCutoverChecks: []string{}, + Evidence: []ChecklistEvidence{}, + } +} + +func (b *checklistBuilder) buildInventoriedSection(name string) ChecklistSection { + sec := newChecklistSection(name) + sec.SourceCount = inventorySectionCount(b.in.Source, name) + sec.DestinationCount = inventorySectionCount(b.in.Destination, name) + sec.SourcePresent = sec.SourceCount > 0 + sec.DestinationPresent = sec.DestinationCount > 0 + sec.MigrationEvidence = b.evidence[name] + sec.MigratedByTool = b.migrated[name] + + findings := b.findings[name] + for _, f := range findings { + sec.PolicyFindingRefs = appendUnique(sec.PolicyFindingRefs, f.ID) + } + sec.Evidence = diffEvidence(b.in.Diff, name) + + // Section-specific expected-difference recognition and action + // generation. downgraded marks findings whose severity no longer + // gates the section. + downgraded := make([]bool, len(findings)) + switch name { + case "domains": + b.evalDomainsSection(&sec, findings) + case "mailboxes", "databases", "forwarders", "autoresponders", "ftp": + b.evalRecreateSection(&sec, name, findings) + case "ssl": + b.evalSSLSection(&sec, findings, downgraded) + case "php": + b.evalPHPSection(&sec, findings) + case "dns": + b.evalDNSSection(&sec, findings, downgraded) + case "cron": + b.evalCronSection(&sec, findings) + case "email_routing": + b.evalEmailRoutingSection(&sec, findings) + case "default_address": + b.evalDefaultAddressSection(&sec, findings) + case "email_filters": + b.evalEmailFiltersSection(&sec, findings) + case "redirects": + b.evalRedirectsSection(&sec, findings) + } + + blockers, reviews := 0, 0 + for i, f := range findings { + if downgraded[i] { + continue + } + switch f.Severity { + case SeverityBlocker: + blockers++ + ref := f.SourceRef + if ref == "" { + ref = f.DestinationRef + } + label := f.ID + if ref != "" { + label = fmt.Sprintf("%s (%s)", f.ID, ref) + } + sec.Blockers = append(sec.Blockers, label) + if blockerScopeCutover[f.ID] { + sec.BlockersCutover = append(sec.BlockersCutover, label) + } else { + sec.BlockersApply = append(sec.BlockersApply, label) + } + case SeverityReview: + reviews++ + } + } + + sec.Status = b.resolveStatus(name, &sec, blockers, reviews, len(findings)) + b.addPostCutoverChecks(&sec) + return sec +} + +// resolveStatus applies the fixed precedence: blocked > has-real-actions +// (manual_required, or not_migrated_by_tool for a non-migratable area whose +// destination is empty) > review_required > expected_difference > +// not_applicable > ok. ACCEPT_EXPECTED_DIFFERENCE acknowledgments never +// change a section's status, and neither do operator-ACCEPTED actions +// (PR 7D): a formally accepted action stops counting as real work. +func (b *checklistBuilder) resolveStatus(name string, sec *ChecklistSection, blockers, reviews, findingsCount int) string { + realActions := 0 + for _, idx := range b.sectionActions[name] { + if b.actions[idx].Type != MActionAcceptExpectedDiff && !b.actions[idx].Accepted { + realActions++ + } + } + switch { + case blockers > 0: + return SectionBlocked + case realActions > 0: + if !checklistMigratable[name] && sec.SourceCount > 0 && sec.DestinationCount == 0 { + return SectionNotMigratedByTool + } + return SectionManualRequired + case reviews > 0: + return SectionReviewRequired + case len(sec.ExpectedDifferences) > 0: + return SectionExpectedDifference + case sec.SourceCount == 0 && sec.DestinationCount == 0 && findingsCount == 0: + return SectionNotApplicable + default: + return SectionOK + } +} + +// checklistMigratable marks the sections the legacy apply flow can migrate. +var checklistMigratable = map[string]bool{ + "domains": true, "mailboxes": true, "databases": true, "web_files": true, +} + +// evalDomainsSection needs no downgraded slice: the only expected +// difference here (docroot) is already info-severity at the policy layer. +func (b *checklistBuilder) evalDomainsSection(sec *ChecklistSection, findings []PolicyFinding) { + for _, f := range findings { + switch f.ID { + case "POL-DOMAIN-DOCROOT-CHANGED": + sec.ExpectedDifferences = append(sec.ExpectedDifferences, ExpectedDifference{ + Key: f.SourceRef, Reason: "document root layouts legitimately differ across hosts", + }) + case "POL-DOMAIN-MAIN-REMOVED": + b.addAction(sec.Section, MActionCreateOnDestination, true, f, + "Create the main domain on the destination", + "Create the domain (or transfer the account) so the destination serves it before cutover.") + case "POL-DOMAIN-REMOVED": + b.addAction(sec.Section, MActionCreateOnDestination, false, f, + "Create the missing domain on the destination", + "Create the addon/sub/parked domain on the destination or confirm it is being dropped.") + } + } +} + +// evalRecreateSection covers the uniform "recreate it by hand" sections. +func (b *checklistBuilder) evalRecreateSection(sec *ChecklistSection, name string, findings []PolicyFinding) { + noun := map[string]string{ + "mailboxes": "mailbox", "databases": "database", "forwarders": "forwarder", + "autoresponders": "autoresponder", "ftp": "FTP account", + }[name] + // Mail flow breaks silently when a mailbox or forwarder is missing; + // a lost database blocks the application outright. + blocking := map[string]bool{"mailboxes": true, "databases": true, "forwarders": true}[name] + for _, f := range findings { + if !strings.HasSuffix(f.ID, "-REMOVED") { + continue + } + b.addAction(sec.Section, MActionCreateOnDestination, blocking, f, + fmt.Sprintf("Recreate %s %s on the destination", noun, f.SourceRef), + fmt.Sprintf("Recreate the %s on the destination or confirm it is obsolete.", noun)) + } +} + +// --- PR 7E section evaluators ---------------------------------------------- + +// evalEmailRoutingSection: a routing difference (or a domain whose +// routing entry disappeared) silently breaks mail delivery - blocking +// per-domain confirmation, replacing the old blanket not_inventoried +// check. +func (b *checklistBuilder) evalEmailRoutingSection(sec *ChecklistSection, findings []PolicyFinding) { + // The routing diff compares the routing MODE only; the exchangers + // live in the dns section. When the dns comparison relevant to a + // routing domain was skipped, the operator must know the MX rrsets + // behind this routing were never verified - a generic "dns + // incomplete" note is not enough. Scoped to the routing domains' + // own zones: an unrelated zone hiccup must not cry wolf. + if dnsSec, ok := b.in.Diff.Sections["dns"]; ok && sec.SourceCount > 0 && + dnsSkipTouchesRouting(dnsSec.Skipped, b.in.Source.EmailRouting.Items) { + b.warnings = append(b.warnings, + "dns comparison was skipped - the MX exchangers behind email routing were not verified; compare them manually before cutover") + } + for _, f := range findings { + switch f.ID { + case "POL-MAILROUTE-REMOVED": + b.addAction(sec.Section, MActionConfirmEmailRouting, true, f, + "Confirm mail routing for "+f.SourceRef, + "The domain has no routing entry on the destination; set cPanel Email Routing (local/remote) before cutover.") + case "POL-MAILROUTE-CHANGED": + b.addAction(sec.Section, MActionConfirmEmailRouting, true, f, + "Confirm mail routing for "+f.SourceRef, + "Email Routing differs between source and destination; a wrong local/remote value silently breaks delivery.") + } + } +} + +// dnsSkipTouchesRouting reports whether a dns Skipped entry affects a +// mail-routing domain. A whole-section skip carries no "zone " prefix +// and leaves every exchanger unverified; a per-zone skip ("zone +// unavailable on one side - records not compared") matters only when +// that zone hosts one of the routing domains. +func dnsSkipTouchesRouting(skipped []string, items []EmailRoutingEntry) bool { + for _, s := range skipped { + rest, perZone := strings.CutPrefix(s, "zone ") + if !perZone { + return true + } + zone := rest + if i := strings.IndexByte(zone, ' '); i >= 0 { + zone = zone[:i] + } + for _, it := range items { + if it.Domain == zone || strings.HasSuffix(it.Domain, "."+zone) { + return true + } + } + } + return false +} + +func (b *checklistBuilder) evalDefaultAddressSection(sec *ChecklistSection, findings []PolicyFinding) { + for _, f := range findings { + switch f.ID { + case "POL-DEFAULTADDR-REMOVED", "POL-DEFAULTADDR-CHANGED": + ref := f.SourceRef + if ref == "" { + ref = f.DestinationRef + } + b.addAction(sec.Section, MActionManualCheckRequired, true, f, + "Check the default (catch-all) address for "+ref, + "The default address differs or is missing on the destination; a lost catch-all silently drops mail.") + } + } +} + +func (b *checklistBuilder) evalEmailFiltersSection(sec *ChecklistSection, findings []PolicyFinding) { + for _, f := range findings { + if f.ID != "POL-EMAILFILTER-REMOVED" { + continue + } + b.addAction(sec.Section, MActionRecreateEmailFilters, true, f, + "Recreate email filter "+f.SourceRef, + "The filter exists only on the source; recreate it on the destination or confirm it is obsolete - filters change mail handling silently.") + } +} + +// evalRedirectsSection: CMS rewrites travel with the web files, so +// their absence on a not-yet-synced destination is an expected +// difference; only genuine redirects get an operator action +// (non-blocking - the .htaccess rule still migrates with webfiles). +func (b *checklistBuilder) evalRedirectsSection(sec *ChecklistSection, findings []PolicyFinding) { + for _, f := range findings { + switch f.ID { + case "POL-REDIRECT-CMS-REMOVED": + sec.ExpectedDifferences = append(sec.ExpectedDifferences, ExpectedDifference{ + Key: f.SourceRef, Reason: "CMS-generated .htaccess rewrite - travels with the web files migration", + }) + case "POL-REDIRECT-REMOVED", "POL-REDIRECT-CHANGED": + ref := f.SourceRef + if ref == "" { + ref = f.DestinationRef + } + b.addAction(sec.Section, MActionConfirmRedirect, false, f, + "Confirm redirect "+ref, + "A genuine redirect differs or is missing on the destination; verify it after the web files migration (its .htaccess rule travels with the files).") + } + } +} + +func (b *checklistBuilder) evalSSLSection(sec *ChecklistSection, findings []PolicyFinding, downgraded []bool) { + now := b.in.Now.Unix() + validDest := func(e SSLEntry) bool { + return e.ValidUntil > now && (e.ValidFrom == 0 || e.ValidFrom <= now) + } + domainCovered := func(dom string) bool { + for _, e := range b.in.Destination.SSL.Items { + if !validDest(e) { + continue + } + for _, d := range strings.Split(e.Domains, ",") { + if certDomainCovers(strings.TrimSpace(d), dom) { + return true + } + } + } + return false + } + // sourceGroupExpired reports whether the source inventory holds at least + // one certificate under this diff key and ALL of them are provably + // expired at Now. Unknown expiry (ValidUntil <= 0) is never proof of + // expiry, and one still-valid generation keeps the whole group live - + // both fail-safe: when in doubt the removal keeps gating. + sourceGroupExpired := func(key string) bool { + found := false + for _, e := range b.in.Source.SSL.Items { + if e.Domains != key { + continue + } + if e.ValidUntil <= 0 || e.ValidUntil > now { + return false + } + found = true + } + return found + } + certByKey := func(key string) (SSLEntry, bool) { + for _, e := range b.in.Destination.SSL.Items { + if e.Domains == key { + return e, true + } + } + return SSLEntry{}, false + } + + expectedKeys := map[string]bool{} + reissueKeys := map[string]bool{} + for i, f := range findings { + key := f.SourceRef + switch f.ID { + case "POL-SSL-CHANGED": + if e, ok := certByKey(key); ok && validDest(e) { + downgraded[i] = true + if !expectedKeys[key] { + expectedKeys[key] = true + sec.ExpectedDifferences = append(sec.ExpectedDifferences, ExpectedDifference{ + Key: key, Reason: "destination presents a different but currently valid certificate for the same domains", + }) + b.addAction(sec.Section, MActionAcceptExpectedDiff, false, f, + "Acknowledge the reissued certificate for "+key, + "The destination certificate differs from the source but is currently valid; acknowledge or investigate.") + } + continue + } + if !reissueKeys[key] { + reissueKeys[key] = true + b.addAction(sec.Section, MActionReissueSSL, false, f, + "Verify or reissue the certificate for "+key, + "The destination certificate differs and its validity could not be confirmed; verify it, reissue via AutoSSL if needed.") + } + case "POL-SSL-REMOVED": + allCovered := key != "" && key != "(no domain list)" + if allCovered { + for _, d := range strings.Split(key, ",") { + if !domainCovered(strings.TrimSpace(d)) { + allCovered = false + break + } + } + } + if allCovered { + downgraded[i] = true + if !expectedKeys[key] { + expectedKeys[key] = true + sec.ExpectedDifferences = append(sec.ExpectedDifferences, ExpectedDifference{ + Key: key, Reason: "certificate regrouped on the destination - every domain is still covered by a valid certificate", + }) + b.addAction(sec.Section, MActionAcceptExpectedDiff, false, f, + "Acknowledge the regrouped certificate for "+key, + "The source certificate no longer exists as-is, but a valid destination certificate covers all of its domains.") + } + continue + } + // A group that was ALREADY expired on the source carries nothing + // valid to migrate: its absence on the destination is expected, + // not a cutover gate (real-smoke finding 2 - old wildcard + // generations kept blocking forever). + if sourceGroupExpired(key) { + downgraded[i] = true + if !expectedKeys[key] { + expectedKeys[key] = true + sec.ExpectedDifferences = append(sec.ExpectedDifferences, ExpectedDifference{ + Key: key, Reason: "every source certificate for these domains was already expired - nothing valid to migrate", + }) + b.addAction(sec.Section, MActionAcceptExpectedDiff, false, f, + "Acknowledge the expired source certificate for "+key, + "All source certificates for these domains were already expired before the migration; issue a destination certificate only if the domains must serve HTTPS.") + } + continue + } + if !reissueKeys[key] { + reissueKeys[key] = true + b.addAction(sec.Section, MActionReissueSSL, true, f, + "Issue a certificate for "+displayOr(key, "(no domain list)"), + "Issue or install a certificate on the destination (AutoSSL or manual) before cutover.") + } + } + } +} + +// certDomainCovers reports whether one certificate domain entry covers dom: +// exact match, or RFC 6125-style wildcard matching - "*.base" covers exactly +// one extra non-empty label ("shop.base" yes; "base" itself and "a.b.base" +// no). A wildcard query is only ever covered by the identical literal +// wildcard entry, never synthesized from per-host coverage. Matching is +// case-sensitive like the rest of the pipeline: cPanel normalizes domains +// to lowercase on both sides. +func certDomainCovers(certDom, dom string) bool { + if certDom == dom { + return true + } + base, isWild := strings.CutPrefix(certDom, "*.") + if !isWild || base == "" || strings.Contains(dom, "*") { + return false + } + label, matched := strings.CutSuffix(dom, "."+base) + return matched && label != "" && !strings.Contains(label, ".") +} + +func (b *checklistBuilder) evalPHPSection(sec *ChecklistSection, findings []PolicyFinding) { + for _, f := range findings { + if f.ID != "POL-PHP-CHANGED" && f.ID != "POL-PHP-REMOVED" { + continue + } + b.addAction(sec.Section, MActionCheckPHPCompat, false, f, + "Check PHP compatibility for "+f.SourceRef, + "Test the site against the destination PHP configuration before cutover.") + } +} + +func (b *checklistBuilder) evalDNSSection(sec *ChecklistSection, findings []PolicyFinding, downgraded []bool) { + hasPlan := b.in.DNSPlan != nil + sawDNSChange := false + + for i, f := range findings { + ref := f.SourceRef + if ref == "" { + ref = f.DestinationRef + } + switch { + case f.ID == "POL-DNS-SOA-CHANGED": + sec.ExpectedDifferences = append(sec.ExpectedDifferences, ExpectedDifference{ + Key: ref, Reason: "SOA serial/timers change whenever a zone is regenerated on a new host", + }) + case f.ID == "POL-DNS-RECORD-CHANGED" || f.ID == "POL-DNS-RECORD-REMOVED": + sawDNSChange = true + op, planned := b.planOpForFindingRef(ref) + switch { + case planned && op.Action == ActionSkip: + // The destination ALREADY matches the plan's desired + // translation: the difference is the intended one. + downgraded[i] = true + sec.ExpectedDifferences = append(sec.ExpectedDifferences, ExpectedDifference{ + Key: ref, Reason: "destination already matches the DNS plan translation (plan action: skip)", + }) + case planned && op.Action == ActionReplace && f.ID == "POL-DNS-RECORD-CHANGED" && + strings.Contains(op.Name, "._domainkey"): + // 7A smoke finding 3: a regenerated DKIM key produces a + // pending plan replace and a policy review, but the + // old-key-vs-regenerated-key choice is a human decision - + // surface it instead of staying silent. Non-blocking: the + // replace itself is already tracked as plan work. + b.addAction(sec.Section, MActionConfirmDNSRecord, false, f, + "Confirm the regenerated DKIM key "+ref, + "The destination regenerated this DKIM TXT (plan: replace). Decide which key is authoritative: keep the destination's regenerated key (and update any external DNS copies) or restore the source key via the plan.") + case !hasPlan && f.ID == "POL-DNS-RECORD-CHANGED" && dnsKeyType(f.SourceRef) == "TXT": + b.addAction(sec.Section, MActionVerifyExternalSvc, false, f, + "Verify the changed TXT record "+f.SourceRef, + "TXT records often bind external services (SPF/DKIM/verification); confirm the destination value is intended.") + } + case f.ID == "POL-DNS-MX-REMOVED" || f.ID == "POL-DNS-MX-CHANGED": + b.addAction(sec.Section, MActionConfirmMXExternal, true, f, + "Confirm mail routing (MX) for "+ref, + "MX records differ between source and destination; confirm external mail (e.g. Microsoft 365 / Google Workspace) keeps working before cutover.") + case f.ID == "POL-DNS-NS-REMOVED" || f.ID == "POL-DNS-NS-CHANGED": + b.addAction(sec.Section, MActionConfirmDNSRecord, true, f, + "Confirm delegation (NS) for "+ref, + "NS records differ; confirm the intended delegation at the registrar/WHM level.") + case f.ID == "POL-DNS-ZONE-REMOVED": + b.addAction(sec.Section, MActionCreateOnDestination, true, f, + "Create the missing DNS zone "+ref, + "The destination does not serve this zone; create it via WHM/park, then re-run the inventory.") + } + } + + if b.in.DNSPlan != nil { + for _, z := range b.in.DNSPlan.Zones { + for _, op := range z.Ops { + if op.Action != ActionManual { + continue + } + b.addPlanManualAction(sec.Section, z.Zone, op) + } + } + for _, mz := range b.in.DNSPlan.ManualZones { + sec.Evidence = append(sec.Evidence, ChecklistEvidence{ + Kind: "plan_manual_zone", Key: mz.Zone, Detail: mz.Reason, + }) + } + } else if sawDNSChange { + b.warnings = append(b.warnings, + "no DNS plan provided - only SOA differences can be recognized as expected; run `inventory dns-plan` and pass it via --dns-plan") + } +} + +// addPlanManualAction maps one manual plan op to the operator action it +// requires. Blocking is decided by the record's nature, not the reason +// text, except for the SPF case which the plan states explicitly. +func (b *checklistBuilder) addPlanManualAction(section, zone string, op PlanOp) { + ev := []ChecklistEvidence{{ + Kind: "plan_manual_op", Key: fmt.Sprintf("zone %s %s %s", zone, op.Type, op.Name), Detail: op.Reason, + }} + derived := []string{fmt.Sprintf("dns-plan:%s:%s:%s", zone, op.Type, op.Name)} + switch { + case op.Type == "TXT" && strings.Contains(op.Reason, "SPF"): + b.addActionRaw(section, MActionUpdateSPF, true, derived, + "Rewrite the SPF TXT record "+op.Name, + op.Reason, "Rewrite the SPF value by hand replacing the old server address, then create it on the destination.", ev) + case op.Type == "MX": + b.addActionRaw(section, MActionConfirmMXExternal, true, derived, + "Resolve the MX record "+op.Name+" by hand", + op.Reason, "The plan refuses to touch this MX rrset; confirm mail routing manually.", ev) + case op.Type == "NS": + b.addActionRaw(section, MActionConfirmDNSRecord, false, derived, + "Review delegation (NS) for "+op.Name, + op.Reason, "NS/delegation is registrar/WHM territory; review it manually.", ev) + case op.Type == "A" || op.Type == "AAAA" || op.Type == "CNAME": + b.addActionRaw(section, MActionConfirmDNSRecord, true, derived, + fmt.Sprintf("Resolve the %s record %s by hand", op.Type, op.Name), + op.Reason, "The plan cannot translate this record; without it the destination will not serve it - resolve before cutover.", ev) + default: + b.addActionRaw(section, MActionConfirmDNSRecord, false, derived, + fmt.Sprintf("Review the %s record %s by hand", op.Type, op.Name), + op.Reason, "The plan does not support this record type; recreate it manually if still needed.", ev) + } +} + +func (b *checklistBuilder) evalCronSection(sec *ChecklistSection, findings []PolicyFinding) { + for _, f := range findings { + switch f.ID { + case "POL-CRON-ENABLED-REMOVED": + typ := MActionRecreateCron + operator := "Recreate this cron job on the destination before cutover." + if strings.Contains(f.SourceRef, "/home/") { + typ = MActionAdaptCronPath + operator = "Recreate this cron job on the destination adapting the /home/ paths to the new account." + } + b.addAction(sec.Section, typ, true, f, + "Recreate active cron job", operator) + case "POL-CRON-DISABLED-REMOVED": + b.addAction(sec.Section, MActionRecreateCron, false, f, + "Recreate disabled cron job (only if still needed)", + "The job was disabled on the source; recreate it only if you plan to re-enable it.") + } + } +} + +func (b *checklistBuilder) buildWebFilesSection() ChecklistSection { + sec := newChecklistSection("web_files") + sec.SourceCount = len(b.in.Source.Domains) + sec.DestinationCount = len(b.in.Destination.Domains) + sec.SourcePresent = sec.SourceCount > 0 + sec.DestinationPresent = sec.DestinationCount > 0 + sec.MigrationEvidence = b.evidence["web_files"] + sec.MigratedByTool = b.migrated["web_files"] + // The inventory carries NO file listing: this section is knowable only + // through migration evidence. + sec.Evidence = append(sec.Evidence, ChecklistEvidence{ + Kind: "note", Detail: "the inventory has no file listing - web files are assessed from migration evidence only", + }) + switch { + case sec.SourceCount == 0: + sec.Status = SectionNotApplicable + case sec.MigratedByTool: + sec.Status = SectionOK + default: + sec.Status = SectionNotMigratedByTool + } + b.addPostCutoverChecks(&sec) + return sec +} + +// buildRootOnlySection reports what account-level access cannot see at +// all. These sections are informational and never gate the rollup: the +// operator cannot fix them from cPanel. +func (b *checklistBuilder) buildRootOnlySection(name string) ChecklistSection { + sec := newChecklistSection(name) + sec.Status = SectionNotAccessibleWithoutRoot + detail := map[string]string{ + "quota_package": "package assignment and account quotas/limits are WHM territory - compare them from WHM if you have access", + "server_level_config": "server-level configuration (PHP handlers, Apache/LiteSpeed, firewall, system crons) is not visible with account-level access", + }[name] + sec.Evidence = append(sec.Evidence, ChecklistEvidence{Kind: "note", Detail: detail}) + return sec +} + +// --------------------------------------------------------------------------- +// Post-cutover checks (fixed, deterministic strings) +// --------------------------------------------------------------------------- + +func (b *checklistBuilder) addPostCutoverChecks(sec *ChecklistSection) { + if sec.SourceCount == 0 { + return + } + switch sec.Section { + case "mailboxes": + sec.PostCutoverChecks = append(sec.PostCutoverChecks, + "Send and receive a test message for at least one mailbox per domain.") + case "dns": + sec.PostCutoverChecks = append(sec.PostCutoverChecks, + "Verify public DNS resolves every domain to the destination server once TTLs expire.") + case "ssl": + sec.PostCutoverChecks = append(sec.PostCutoverChecks, + "Run AutoSSL on the destination and confirm every domain serves a valid certificate.") + case "web_files": + sec.PostCutoverChecks = append(sec.PostCutoverChecks, + "Load every site over HTTPS and confirm the homepage renders from the destination.") + case "cron": + sec.PostCutoverChecks = append(sec.PostCutoverChecks, + "Confirm recreated cron jobs actually ran (check their output/log once).") + } +} + +// --------------------------------------------------------------------------- +// Rollup +// --------------------------------------------------------------------------- + +func (b *checklistBuilder) summarize(c *MigrationChecklist) { + for _, s := range c.Sections { + switch s.Status { + case SectionOK: + c.Summary.OK++ + case SectionReviewRequired: + c.Summary.ReviewRequired++ + case SectionBlocked: + c.Summary.Blocked++ + case SectionNotMigratedByTool: + c.Summary.NotMigratedByTool++ + case SectionNotInventoried: + c.Summary.NotInventoried++ + case SectionNotAccessibleWithoutRoot: + c.Summary.NotAccessibleWithoutRoot++ + } + c.Summary.ExpectedDifferences += len(s.ExpectedDifferences) + } + c.Summary.ManualActions = len(c.ManualActions) + + blockingActions := false + for _, a := range c.ManualActions { + if a.Accepted { + c.Summary.Accepted++ + continue // a formally accepted action no longer gates + } + if a.BlockingCutover { + blockingActions = true + } + } + notes := len(c.ManualActions) > 0 + for _, s := range c.Sections { + switch s.Status { + case SectionReviewRequired, SectionManualRequired, SectionNotMigratedByTool, + SectionNotInventoried, SectionExpectedDifference: + notes = true + } + } + + switch { + case c.Summary.Blocked > 0: + c.OverallStatus = OverallBlocked + case blockingActions: + c.OverallStatus = OverallManualActionRequired + case b.coreEvidenceMissing(): + c.OverallStatus = OverallNotReady + case notes: + c.OverallStatus = OverallReadyWithManualNotes + default: + c.OverallStatus = OverallReadyToCutover + } + + // ApplyBlocked: true only if there are apply-scope blockers. + for _, s := range c.Sections { + if len(s.BlockersApply) > 0 { + c.ApplyBlocked = true + break + } + } + + // A PROVEN provenance mismatch means the artifacts were not derived + // from these inventories: any READY_* verdict is unreliable and is + // capped to NOT_READY. Worse verdicts stand - the cap never improves. + if b.chainMismatch && + (c.OverallStatus == OverallReadyToCutover || c.OverallStatus == OverallReadyWithManualNotes) { + c.OverallStatus = OverallNotReady + } +} + +// verifyProvenanceChain compares the hashes the CALLER computed for the +// raw input files (InputRefs) against the hashes each artifact records +// about its OWN inputs. It never hashes anything itself. Missing hashes +// (artifacts produced before PR 7B, or programmatic use without refs) +// leave the chain unverified without gating; a mismatch is evidence the +// composition is inconsistent and is reported for the overall cap. +func verifyProvenanceChain(in ChecklistInput) (verified bool, warnings []string, mismatch bool) { + refs := in.InputRefs + var emptyRefs []string + if refs.SourceInventory.SHA256 == "" { + emptyRefs = append(emptyRefs, "source inventory") + } + if refs.DestinationInventory.SHA256 == "" { + emptyRefs = append(emptyRefs, "destination inventory") + } + if refs.Diff.SHA256 == "" { + emptyRefs = append(emptyRefs, "diff") + } + if len(emptyRefs) == 3 { + return false, nil, false // fully programmatic use: nothing to verify against + } + + var missing []string + // check compares one recorded-vs-expected link; an empty expected ref + // means the caller could not provide it - the link is skipped here and + // reported once via emptyRefs (partial refs must never be silent). + check := func(artifact, input, recorded, expected string) { + switch { + case expected == "": + // reported via emptyRefs + case recorded == "": + missing = append(missing, fmt.Sprintf("%s does not record the hash of its %s", artifact, input)) + case recorded != expected: + mismatch = true + warnings = append(warnings, fmt.Sprintf( + "provenance chain mismatch: %s was generated from a DIFFERENT %s (hash mismatch) - regenerate the pipeline from fresh inventories", + artifact, input)) + } + } + check("diff", "source inventory", in.Diff.SourceSHA256, refs.SourceInventory.SHA256) + check("diff", "destination inventory", in.Diff.DestinationSHA256, refs.DestinationInventory.SHA256) + check("policy report", "diff", in.Policy.InputDiffSHA256, refs.Diff.SHA256) + if in.DNSPlan != nil { + check("dns plan", "source inventory", in.DNSPlan.SourceSHA256, refs.SourceInventory.SHA256) + check("dns plan", "destination inventory", in.DNSPlan.DestinationSHA256, refs.DestinationInventory.SHA256) + } + if len(emptyRefs) > 0 { + missing = append(missing, "the caller provided no reference hash for: "+strings.Join(emptyRefs, ", ")) + } + if len(missing) > 0 { + warnings = append(warnings, "provenance chain not verifiable: "+ + strings.Join(missing, "; ")+" (artifact generated before PR 7B?)") + } + return !mismatch && len(missing) == 0, warnings, mismatch +} + +// coreEvidenceMissing: an area the tool is SUPPOSED to migrate has data on +// the source and no migration evidence at all. +func (b *checklistBuilder) coreEvidenceMissing() bool { + if len(b.in.Source.Mailboxes) > 0 && b.evidence["mailboxes"] == EvidenceNone { + return true + } + if len(b.in.Source.Databases) > 0 && b.evidence["databases"] == EvidenceNone { + return true + } + if len(b.in.Source.Domains) > 0 && b.evidence["web_files"] == EvidenceNone { + return true + } + return false +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// addAction records one manual action derived from a policy finding. +func (b *checklistBuilder) addAction(section, typ string, blocking bool, f PolicyFinding, title, operator string) { + ref := f.SourceRef + if ref == "" { + ref = f.DestinationRef + } + ev := []ChecklistEvidence{} + if ref != "" || f.Detail != "" { + ev = append(ev, ChecklistEvidence{Kind: "policy_finding", Key: ref, Detail: f.Detail}) + } + b.addActionRaw(section, typ, blocking, []string{f.ID}, title, f.Detail, operator, ev) +} + +func (b *checklistBuilder) addActionRaw(section, typ string, blocking bool, derivedFrom []string, title, detail, operator string, ev []ChecklistEvidence) { + if ev == nil { + ev = []ChecklistEvidence{} + } + // CONFIRM_MX_EXTERNAL and a blocking cron recreation (with or without + // path adaptation) must be resolved, not waved through: they are + // non-acceptable for the operator acceptance flow (PR 7D). + acceptable := typ != MActionConfirmMXExternal && + !(blocking && (typ == MActionRecreateCron || typ == MActionAdaptCronPath)) + a := ManualAction{ + Key: manualActionKey(typ, section, title, detail), + Type: typ, Section: section, BlockingCutover: blocking, + DerivedFrom: derivedFrom, Title: title, Detail: detail, + Evidence: ev, OperatorAction: operator, Acceptable: acceptable, + } + if acc, ok := b.acceptByKey[a.Key]; ok { + if !b.acceptMatched[a.Key] { + b.acceptMatched[a.Key] = true + if a.Acceptable { + a.Accepted = true + a.AcceptedBy = acc.AcceptedBy + a.AcceptedAt = acc.AcceptedAt + a.AcceptedReason = acc.Reason + } else { + b.warnings = append(b.warnings, fmt.Sprintf( + "acceptances: action %s (%s) is not acceptable - it must be resolved, the acceptance was ignored", a.Key, typ)) + } + } else { + // Two structurally identical actions (e.g. the same cron job + // scheduled twice, both lost) share the same content key. Only + // the FIRST matching action was accepted - say so, or the + // second would silently keep gating with no explanation. + b.warnings = append(b.warnings, fmt.Sprintf( + "acceptances: key %s matches more than one identical action - only the first was accepted, the other(s) still require attention", a.Key)) + } + } + b.actions = append(b.actions, a) + b.sectionActions[section] = append(b.sectionActions[section], len(b.actions)-1) +} + +// manualActionKey derives the STABLE acceptance handle of an action from +// its content: same fact -> same key across regenerations; changed fact -> +// changed key, so stale acceptances stop matching (fail-safe). NUL framing +// prevents field-boundary collisions. +func manualActionKey(typ, section, title, detail string) string { + sum := sha256.Sum256([]byte(typ + "\x00" + section + "\x00" + title + "\x00" + detail)) + return fmt.Sprintf("AK-%x", sum[:6]) +} + +// planOpForFindingRef resolves a policy DNS finding ref ("zone +// ") to the plan op for that rrset, canonicalizing the owner +// name the same way the plan does. +func (b *checklistBuilder) planOpForFindingRef(ref string) (PlanOp, bool) { + fields := strings.Fields(ref) + if len(fields) < 3 || fields[0] != "zone" { + return PlanOp{}, false + } + zone, typ, name := fields[1], fields[2], "" + if len(fields) >= 4 { + name = fields[3] + } + op, ok := b.planOps[planOpKey(zone, typ, canonDNSName(name, zone))] + return op, ok +} + +func planOpKey(zone, typ, canonicalName string) string { + return strings.ToLower(zone) + "\x00" + strings.ToUpper(typ) + "\x00" + canonicalName +} + +// diffEvidence converts one diff section into already-safe evidence +// pointers (diff keys and details are redacted/preview-safe upstream). +func diffEvidence(d InventoryDiff, name string) []ChecklistEvidence { + out := []ChecklistEvidence{} + sec, ok := d.Sections[name] + if !ok { + return out + } + for _, e := range sec.Removed { + out = append(out, ChecklistEvidence{Kind: "missing_on_destination", Key: e.Key, Detail: e.Detail}) + } + for _, e := range sec.Added { + out = append(out, ChecklistEvidence{Kind: "destination_only", Key: e.Key, Detail: e.Detail}) + } + for _, ch := range sec.Changed { + out = append(out, ChecklistEvidence{ + Kind: "differs", Key: ch.Key, + Detail: fmt.Sprintf("%s: %s -> %s", ch.Field, ch.Source, ch.Destination), + }) + } + for _, s := range sec.Skipped { + out = append(out, ChecklistEvidence{Kind: "comparison_skipped", Detail: s}) + } + return out +} + +func inventorySectionCount(inv NormalizedInventory, name string) int { + switch name { + case "domains": + return len(inv.Domains) + case "mailboxes": + return len(inv.Mailboxes) + case "databases": + return len(inv.Databases) + case "forwarders": + return len(inv.Forwarders) + case "autoresponders": + return len(inv.Autoresponders) + case "ftp": + return len(inv.FTP.Items) + case "ssl": + return len(inv.SSL.Items) + case "php": + return len(inv.PHP.Items) + case "dns": + return len(inv.DNS.Zones) + case "cron": + return len(inv.Cron.Jobs) + case "email_routing": + return len(inv.EmailRouting.Items) + case "default_address": + return len(inv.DefaultAddresses.Items) + case "email_filters": + return len(inv.EmailFilters.Items) + case "redirects": + return len(inv.Redirects.Items) + } + return 0 +} + +func appendUnique(list []string, s string) []string { + for _, v := range list { + if v == s { + return list + } + } + list = append(list, s) + sort.Strings(list) + return list +} + +func displayOr(s, fallback string) string { + if s == "" { + return fallback + } + return s +} diff --git a/internal/accountinventory/checklist_chain_test.go b/internal/accountinventory/checklist_chain_test.go new file mode 100644 index 00000000..b5306891 --- /dev/null +++ b/internal/accountinventory/checklist_chain_test.go @@ -0,0 +1,262 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "strings" + "testing" +) + +// Chain fixtures: the engine never hashes files itself - it compares the +// hashes the CALLER computed (InputRefs) with the hashes each artifact +// recorded about its OWN inputs. Opaque strings are enough here. +const ( + shaSrc = "sha-source" + shaDest = "sha-destination" + shaDiff = "sha-diff" + shaPol = "sha-policy" + shaPlan = "sha-plan" +) + +func chainRefs(withPlan bool) ChecklistInputs { + refs := ChecklistInputs{ + SourceInventory: ChecklistInputRef{File: "s.json", SHA256: shaSrc, Present: true}, + DestinationInventory: ChecklistInputRef{File: "d.json", SHA256: shaDest, Present: true}, + Diff: ChecklistInputRef{File: "diff.json", SHA256: shaDiff, Present: true}, + Policy: ChecklistInputRef{File: "pol.json", SHA256: shaPol, Present: true}, + } + if withPlan { + refs.DNSPlan = ChecklistInputRef{File: "plan.json", SHA256: shaPlan, Present: true} + } + return refs +} + +// chainInput builds a no-mail input (no blocking actions) with full +// apply evidence, so the un-capped overall is READY_TO_CUTOVER: +// the chain cap is observable. +func chainInput(t *testing.T, mutate func(in *ChecklistInput)) ChecklistInput { + t.Helper() + src := chkInventory("source", "192.0.2.1", "srcacct") + src.Mailboxes = nil + src.Forwarders = nil + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Mailboxes = nil + dest.Forwarders = nil + in := chkInput(src, dest, nil, chkApplyReport()) + in.Diff.SourceSHA256, in.Diff.DestinationSHA256 = shaSrc, shaDest + in.Policy.InputDiffSHA256 = shaDiff + in.InputRefs = chainRefs(false) + if mutate != nil { + mutate(&in) + } + return in +} + +func chainWarnings(c MigrationChecklist) []string { + var out []string + for _, w := range c.Warnings { + if strings.Contains(w, "provenance") { + out = append(out, w) + } + } + return out +} + +func TestChecklistChainVerified(t *testing.T) { + in := chainInput(t, nil) + c := BuildChecklist(in) + + if !c.ChainVerified { + t.Fatalf("chain_verified = false, want true (warnings: %v)", c.Warnings) + } + if ws := chainWarnings(c); len(ws) != 0 { + t.Errorf("unexpected chain warnings: %v", ws) + } + if c.Inputs != in.InputRefs { + t.Error("checklist inputs must carry the caller's refs verbatim") + } + if c.OverallStatus != OverallReadyToCutover { + t.Errorf("overall = %q, want %q (fixture contract)", c.OverallStatus, OverallReadyToCutover) + } +} + +func TestChecklistChainVerifiedWithPlan(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + src.Mailboxes = nil + src.Forwarders = nil + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Mailboxes = nil + dest.Forwarders = nil + plan, err := BuildDNSPlan(src, dest, nil, map[string]string{"192.0.2.1": "192.0.2.1"}) + if err != nil { + t.Fatal(err) + } + plan.SourceSHA256, plan.DestinationSHA256 = shaSrc, shaDest + + in := chkInput(src, dest, &plan, chkApplyReport()) + in.Diff.SourceSHA256, in.Diff.DestinationSHA256 = shaSrc, shaDest + in.Policy.InputDiffSHA256 = shaDiff + in.InputRefs = chainRefs(true) + + c := BuildChecklist(in) + if !c.ChainVerified { + t.Fatalf("chain with matching plan hashes: verified = false (warnings: %v)", c.Warnings) + } + + // A plan built from DIFFERENT inventories must break the chain. + plan.SourceSHA256 = "sha-of-someone-else" + c = BuildChecklist(in) + if c.ChainVerified { + t.Fatal("plan from different inventories must not verify") + } + found := false + for _, w := range chainWarnings(c) { + if strings.Contains(w, "mismatch") { + found = true + } + } + if !found { + t.Errorf("want a mismatch chain warning, got %v", c.Warnings) + } +} + +func TestChecklistChainNotVerifiableOnOldArtifacts(t *testing.T) { + in := chainInput(t, func(in *ChecklistInput) { + in.Diff.SourceSHA256 = "" // artifact predates PR 7B + in.Diff.DestinationSHA256 = "" + }) + c := BuildChecklist(in) + + if c.ChainVerified { + t.Fatal("chain_verified = true with a diff that records no input hashes") + } + found := false + for _, w := range chainWarnings(c) { + if strings.Contains(w, "not verifiable") { + found = true + } + } + if !found { + t.Errorf("want a 'not verifiable' chain warning, got %v", c.Warnings) + } + // Missing hashes are tolerated (old artifacts): no overall cap. + if c.OverallStatus != OverallReadyToCutover { + t.Errorf("overall = %q, want %q (absence must not cap)", c.OverallStatus, OverallReadyToCutover) + } +} + +func TestChecklistChainMismatchCapsOverall(t *testing.T) { + in := chainInput(t, func(in *ChecklistInput) { + in.Diff.SourceSHA256 = "sha-of-a-different-inventory" + }) + c := BuildChecklist(in) + + if c.ChainVerified { + t.Fatal("chain_verified = true on a proven hash mismatch") + } + found := false + for _, w := range chainWarnings(c) { + if strings.Contains(w, "mismatch") { + found = true + } + } + if !found { + t.Fatalf("want a mismatch chain warning, got %v", c.Warnings) + } + // A PROVEN inconsistency means the whole composition is unreliable: + // a READY_* verdict must be capped to NOT_READY. + if c.OverallStatus != OverallNotReady { + t.Errorf("overall = %q, want %q (mismatch must cap READY_*)", c.OverallStatus, OverallNotReady) + } +} + +func TestChecklistChainMismatchDoesNotHideBlockers(t *testing.T) { + in := chainInput(t, func(in *ChecklistInput) { + in.Diff.SourceSHA256 = "sha-of-a-different-inventory" + }) + // Recompute diff/policy with a blocker: mailbox lost. + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Mailboxes = []MailboxEntry{} + withBlocker := chkInput(src, dest, nil, chkApplyReport()) + in.Source, in.Destination = src, dest + in.Diff, in.Policy = withBlocker.Diff, withBlocker.Policy + in.Diff.SourceSHA256, in.Diff.DestinationSHA256 = "sha-of-a-different-inventory", shaDest + in.Policy.InputDiffSHA256 = shaDiff + + c := BuildChecklist(in) + if c.OverallStatus != OverallBlocked { + t.Errorf("overall = %q, want %q (the cap must never IMPROVE a verdict)", c.OverallStatus, OverallBlocked) + } + if c.ChainVerified { + t.Error("chain_verified must stay false on mismatch") + } +} + +// Partially-filled refs (a programmatic caller forgetting one) must NOT +// be silent like the fully-empty case: the checkable links are still +// checked, the missing reference is warned about, and the chain can +// never verify. +func TestChecklistChainPartialRefs(t *testing.T) { + // Diff ref missing: policy->diff cannot be checked, warn about it; + // diff->inventories links are still checkable and match -> no mismatch. + in := chainInput(t, func(in *ChecklistInput) { + in.InputRefs.Diff = ChecklistInputRef{} + }) + c := BuildChecklist(in) + if c.ChainVerified { + t.Fatal("chain_verified = true with a missing diff reference") + } + found := false + for _, w := range chainWarnings(c) { + if strings.Contains(w, "no reference hash") { + found = true + } + } + if !found { + t.Errorf("want a 'no reference hash' warning for the missing diff ref, got %v", c.Warnings) + } + if c.OverallStatus != OverallReadyToCutover { + t.Errorf("overall = %q, want %q (a missing ref is absence, not mismatch)", c.OverallStatus, OverallReadyToCutover) + } + + // Same partial refs, but the still-checkable diff->source link + // MISMATCHES: it must be detected and cap the verdict. + in = chainInput(t, func(in *ChecklistInput) { + in.InputRefs.Diff = ChecklistInputRef{} + in.Diff.SourceSHA256 = "sha-of-a-different-inventory" + }) + c = BuildChecklist(in) + if c.ChainVerified { + t.Fatal("chain_verified = true on a mismatch behind partial refs") + } + mismatch := false + for _, w := range chainWarnings(c) { + if strings.Contains(w, "mismatch") { + mismatch = true + } + } + if !mismatch { + t.Errorf("partial refs must not silence a checkable mismatch, got %v", c.Warnings) + } + if c.OverallStatus != OverallNotReady { + t.Errorf("overall = %q, want %q", c.OverallStatus, OverallNotReady) + } +} + +func TestChecklistChainEmptyRefsStaysUnverified(t *testing.T) { + in := chainInput(t, func(in *ChecklistInput) { + in.InputRefs = ChecklistInputs{} + }) + c := BuildChecklist(in) + if c.ChainVerified { + t.Fatal("chain_verified = true without any input refs") + } + if len(chainWarnings(c)) != 0 { + t.Errorf("programmatic use without refs must not warn, got %v", c.Warnings) + } + if c.OverallStatus != OverallReadyToCutover { + t.Errorf("overall = %q, want %q (no refs must not cap)", c.OverallStatus, OverallReadyToCutover) + } +} diff --git a/internal/accountinventory/checklist_safety_test.go b/internal/accountinventory/checklist_safety_test.go new file mode 100644 index 00000000..20f0d602 --- /dev/null +++ b/internal/accountinventory/checklist_safety_test.go @@ -0,0 +1,99 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestChecklistSourcesAreOfflineByConstruction pins the read-only, +// offline invariant of the checklist line of work (PR 7A): no checklist +// source may import a network/SSH/cPanel-client package or reference a +// write primitive. The checklist composes artifact FILES - nothing else. +func TestChecklistSourcesAreOfflineByConstruction(t *testing.T) { + forbiddenImports := []string{ + "internal/sshx", "internal/cpanel", "internal/migrate", + "golang.org/x/crypto/ssh", "\"net\"", "\"net/http\"", + } + writeCalls := []string{ + "mass_edit_zone", "add_pop", "addaddondomain", "addsubdomain", + "create_user", "create_database", "set_privileges", + // email-config writers (PR 2B-1/2B-3): the checklist stays offline. + "add_forwarder", "set_default_address", "delete_forwarder", + "add_auto_responder", "delete_auto_responder", + "store_filter", "delete_filter", "setmxcheck", + // cron writer (PR 2A): the checklist stays offline. + "InstallCrontab", "crontab -", + // DNS writer (PR 6D): the checklist stays offline. + "mass_edit_zone", + } + files, err := filepath.Glob("checklist*.go") + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no checklist*.go files found - glob broken?") + } + for _, f := range files { + if f == "checklist_safety_test.go" { + continue // this file names the verbs on purpose + } + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(b), "\n") { + code, _, _ := strings.Cut(strings.TrimSpace(line), "//") + for _, imp := range forbiddenImports { + if strings.Contains(code, imp) { + t.Errorf("%s:%d imports/references %q - the checklist is offline by construction", f, i+1, imp) + } + } + for _, verb := range writeCalls { + if strings.Contains(code, verb) { + t.Errorf("%s:%d references write call %q in code - the checklist never applies anything", f, i+1, verb) + } + } + } + } +} + +// TestChecklistNeverClaimsEvidenceWithoutReport is the honesty invariant +// in its most direct form: across every section of a checklist built +// WITHOUT a migration report, migrated_by_tool is false and the evidence +// level is "none" - even though source and destination are identical. +func TestChecklistNeverClaimsEvidenceWithoutReport(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + + c := BuildChecklist(chkInput(src, dest, nil, nil)) + + for _, s := range c.Sections { + if s.MigratedByTool { + t.Errorf("section %s claims migrated_by_tool without any migration report", s.Section) + } + if s.MigrationEvidence != EvidenceNone { + t.Errorf("section %s evidence = %q without any migration report", s.Section, s.MigrationEvidence) + } + } +} + +// TestChecklistEvidenceNeverPerItem pins that v0 cannot produce per_item +// evidence: the apply flow does not emit per-item events yet (PR 7C), so +// any per_item value here would be an invented claim. +func TestChecklistEvidenceNeverPerItem(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + for _, s := range c.Sections { + if s.MigrationEvidence == EvidencePerItem { + t.Errorf("section %s claims per_item evidence - nothing can produce it before PR 7C", s.Section) + } + } +} diff --git a/internal/accountinventory/checklist_test.go b/internal/accountinventory/checklist_test.go new file mode 100644 index 00000000..5122ec76 --- /dev/null +++ b/internal/accountinventory/checklist_test.go @@ -0,0 +1,1442 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "reflect" + "regexp" + "strings" + "testing" + "time" +) + +// chkNow is the injected reference time for certificate-validity checks: +// 2027-01-15T08:00:00Z as a fixed epoch, so tests never depend on the +// wall clock. +var chkNow = time.Unix(1_800_000_000, 0).UTC() + +const ( + chkCertValidUntil = int64(1_900_000_000) // after chkNow -> valid + chkCertExpiredUntil = int64(1_700_000_000) // before chkNow -> expired +) + +// chkInventory builds a rich, fully-available inventory. Both sides are +// built independently with IDENTICAL data by default, so the base diff is +// clean; tests mutate one side explicitly. +func chkInventory(side, host, user string) NormalizedInventory { + inv := NewEmptyInventory(user, host, side) + inv.Account.CollectedAt = "2026-07-02T00:00:00Z" // fixed: determinism tests compare full structs + inv.Domains = []DomainEntry{{Name: "main.example", Type: "main", DocumentRoot: "/home/acct/public_html"}} + inv.Mailboxes = []MailboxEntry{{Email: "info@main.example", Domain: "main.example", User: "info"}} + inv.Databases = []DatabaseEntry{{Name: "acct_db1", Users: []string{"acct_dbu"}}} + inv.Forwarders = []NormForwarderEntry{{Source: "fwd@main.example", Destination: "info@main.example", Domain: "main.example"}} + inv.FTP.Available = true + inv.SSL.Available = true + inv.SSL.Items = []SSLEntry{{ + Domains: "main.example", Issuer: "R3", ValidFrom: 1_690_000_000, + ValidUntil: chkCertValidUntil, ValidationType: "dv", + }} + inv.PHP.Available = true + inv.PHP.Items = []PHPEntry{{Domain: "main.example", Version: "ea-php81"}} + inv.DNS.Available = true + inv.DNS.Zones = []DNSZoneResult{planZone("main.example", + aRec("main.example.", "192.0.2.1", 300), + mxRec("main.example.", "main.example.", 0, 300), + )} + inv.Cron.Available = true + inv.Cron.Jobs = []CronJobEntry{{ + Type: "standard", Minute: "5", Hour: "1", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "php /home/acct/cronjob.php --token=****", + CommandSHA256: "sha256:aaa", RawLineSHA256: "sha256:bbb", Enabled: true, LineNumber: 1, + Warnings: []string{}, + }} + inv.EmailRouting.Available = true + inv.EmailRouting.Items = []EmailRoutingEntry{{ + Domain: "main.example", Routing: "local", Detected: "local", AlwaysAccept: true, + MXRecords: []MXRecordEntry{{Priority: 0, Exchange: "main.example"}}, + }} + inv.DefaultAddresses.Available = true + inv.DefaultAddresses.Items = []DefaultAddressEntry{{ + Domain: "main.example", DefaultAddress: `":fail: No Such User Here"`, + }} + inv.EmailFilters.Available = true + inv.EmailFilters.Items = []NormEmailFilterEntry{} + inv.Redirects.Available = true + inv.Redirects.Items = []RedirectEntry{} + return inv +} + +// chkEmptyInventory is a degenerate, fully-available inventory with no +// data at all: it pins the READY_TO_CUTOVER rollup logic. +func chkEmptyInventory(side, host, user string) NormalizedInventory { + inv := NewEmptyInventory(user, host, side) + inv.Account.CollectedAt = "2026-07-02T00:00:00Z" + inv.FTP.Available = true + inv.SSL.Available = true + inv.PHP.Available = true + inv.DNS.Available = true + inv.Cron.Available = true + inv.EmailRouting.Available = true + inv.DefaultAddresses.Available = true + inv.EmailFilters.Available = true + inv.Redirects.Available = true + return inv +} + +func chkApplyReport() *MigrationReportInfo { + return &MigrationReportInfo{ + RunID: "run-1", Mode: "apply", + Scope: MigrationReportScope{Mail: true, Files: true, Databases: true}, + ExitStatus: "success", + } +} + +// chkApplyReportWithPhases is chkApplyReport plus a phases_completed list, +// as a PR 7C apply run records it in report.json. +func chkApplyReportWithPhases(phases ...string) *MigrationReportInfo { + r := chkApplyReport() + r.PhasesCompleted = phases + return r +} + +// chkAllApplyPhases is the full phase set of a whole-scope apply run. +var chkAllApplyPhases = []string{ + "create_domains", "migrate_mail", "verify_mail", + "copy_files", "verify_files", "migrate_db", "verify_db", +} + +// chkInput wires the real pipeline: diff and policy are computed from the +// inventories, never hand-built. +func chkInput(src, dest NormalizedInventory, plan *DNSPlan, rep *MigrationReportInfo) ChecklistInput { + d := DiffInventories(src, dest) + p := EvaluatePolicy(d) + return ChecklistInput{ + Source: src, Destination: dest, Diff: d, Policy: p, + DNSPlan: plan, MigrationReport: rep, Now: chkNow, + } +} + +func chkSection(t *testing.T, c MigrationChecklist, name string) ChecklistSection { + t.Helper() + for _, s := range c.Sections { + if s.Section == name { + return s + } + } + t.Fatalf("section %q not found (sections: %d)", name, len(c.Sections)) + return ChecklistSection{} +} + +func chkActionsOf(c MigrationChecklist, section, typ string) []ManualAction { + var out []ManualAction + for _, a := range c.ManualActions { + if a.Section == section && a.Type == typ { + out = append(out, a) + } + } + return out +} + +// --------------------------------------------------------------------------- +// Overall status rollup +// --------------------------------------------------------------------------- + +func TestBuildChecklistOverallBlocked(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Mailboxes = []MailboxEntry{} // mailbox lost -> policy blocker + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + if c.OverallStatus != OverallBlocked { + t.Fatalf("overall = %q, want %q", c.OverallStatus, OverallBlocked) + } + mb := chkSection(t, c, "mailboxes") + if mb.Status != SectionBlocked { + t.Errorf("mailboxes status = %q, want %q", mb.Status, SectionBlocked) + } + found := false + for _, b := range mb.Blockers { + if strings.Contains(b, "POL-MAILBOX-REMOVED") { + found = true + } + } + if !found { + t.Errorf("mailboxes blockers = %v, want POL-MAILBOX-REMOVED", mb.Blockers) + } + if acts := chkActionsOf(c, "mailboxes", MActionCreateOnDestination); len(acts) != 1 || !acts[0].BlockingCutover { + t.Errorf("mailbox CREATE_ON_DESTINATION blocking action = %+v, want exactly 1 blocking", acts) + } +} + +func TestBuildChecklistManualActionRequiredFromEmailRoutingChange(t *testing.T) { + // PR 7E: email routing is a real section now. A routing-mode change + // (local -> remote) silently breaks delivery, so it must produce a + // blocking per-domain confirmation. + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.EmailRouting.Items[0].Routing = "remote" + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + if c.OverallStatus != OverallManualActionRequired { + t.Fatalf("overall = %q, want %q", c.OverallStatus, OverallManualActionRequired) + } + er := chkSection(t, c, "email_routing") + if er.Status != SectionManualRequired { + t.Errorf("email_routing status = %q, want %q", er.Status, SectionManualRequired) + } + if acts := chkActionsOf(c, "email_routing", MActionConfirmEmailRouting); len(acts) != 1 || !acts[0].BlockingCutover { + t.Errorf("CONFIRM_EMAIL_ROUTING = %+v, want exactly 1 blocking action", acts) + } +} + +func TestBuildChecklistNotReadyWithoutMigrationEvidence(t *testing.T) { + // No mailboxes/forwarders (no blocking synthetics), but domains and a + // database exist on the source and there is NO migration report: core + // areas have zero migration evidence -> NOT_READY. + src := chkInventory("source", "192.0.2.1", "srcacct") + src.Mailboxes = []MailboxEntry{} + src.Forwarders = []NormForwarderEntry{} + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Mailboxes = []MailboxEntry{} + dest.Forwarders = []NormForwarderEntry{} + + c := BuildChecklist(chkInput(src, dest, nil, nil)) + + if c.OverallStatus != OverallNotReady { + t.Fatalf("overall = %q, want %q", c.OverallStatus, OverallNotReady) + } + wf := chkSection(t, c, "web_files") + if wf.Status != SectionNotMigratedByTool { + t.Errorf("web_files status = %q, want %q", wf.Status, SectionNotMigratedByTool) + } + if wf.MigratedByTool || wf.MigrationEvidence != EvidenceNone { + t.Errorf("web_files evidence = (%v, %q), want (false, none)", wf.MigratedByTool, wf.MigrationEvidence) + } +} + +func TestBuildChecklistReadyWithManualNotes(t *testing.T) { + // Same no-mail account WITH full apply evidence: only a non-blocking + // redirect confirmation remains. + src := chkInventory("source", "192.0.2.1", "srcacct") + src.Mailboxes = []MailboxEntry{} + src.Forwarders = []NormForwarderEntry{} + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Mailboxes = []MailboxEntry{} + dest.Forwarders = []NormForwarderEntry{} + + // A genuine (non-CMS) redirect whose destination differs: the only + // remaining note is the non-blocking CONFIRM_REDIRECT. + src.Redirects.Items = []RedirectEntry{{ + Domain: "main.example", Source: "/old", Destination: "https://a.example/", + Kind: "redirect", Type: "permanent", StatusCode: 301, + }} + dest.Redirects.Items = []RedirectEntry{{ + Domain: "main.example", Source: "/old", Destination: "https://b.example/", + Kind: "redirect", Type: "permanent", StatusCode: 301, + }} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + if c.OverallStatus != OverallReadyWithManualNotes { + t.Fatalf("overall = %q, want %q", c.OverallStatus, OverallReadyWithManualNotes) + } + rd := chkSection(t, c, "redirects") + if rd.Status != SectionManualRequired { + t.Errorf("redirects status = %q, want %q", rd.Status, SectionManualRequired) + } + if acts := chkActionsOf(c, "redirects", MActionConfirmRedirect); len(acts) != 1 || acts[0].BlockingCutover { + t.Errorf("CONFIRM_REDIRECT = %+v, want exactly 1 non-blocking", acts) + } + for _, a := range c.ManualActions { + if a.BlockingCutover { + t.Errorf("unexpected blocking action %s/%s", a.Section, a.Type) + } + } +} + +func TestBuildChecklistReadyToCutoverOnEmptyAccount(t *testing.T) { + // Degenerate empty account: no core data -> no evidence needed, no + // synthetic notes apply. Pins the READY_TO_CUTOVER branch. + src := chkEmptyInventory("source", "192.0.2.1", "srcacct") + dest := chkEmptyInventory("destination", "192.0.2.2", "srcacct") + + c := BuildChecklist(chkInput(src, dest, nil, nil)) + + if c.OverallStatus != OverallReadyToCutover { + t.Fatalf("overall = %q, want %q", c.OverallStatus, OverallReadyToCutover) + } + for _, name := range []string{"email_routing", "default_address", "email_filters", "redirects"} { + if s := chkSection(t, c, name); s.Status != SectionNotApplicable { + t.Errorf("%s status = %q, want %q", name, s.Status, SectionNotApplicable) + } + } + // Root-only sections are always reported but never gate. + for _, name := range []string{"quota_package", "server_level_config"} { + if s := chkSection(t, c, name); s.Status != SectionNotAccessibleWithoutRoot { + t.Errorf("%s status = %q, want %q", name, s.Status, SectionNotAccessibleWithoutRoot) + } + } +} + +// --------------------------------------------------------------------------- +// migrated_by_tool honesty +// --------------------------------------------------------------------------- + +func TestBuildChecklistEvidenceHonesty(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + + tests := []struct { + name string + report *MigrationReportInfo + wantEvidence string + wantMigrated bool + wantWarning string + }{ + {name: "no report", report: nil, wantEvidence: EvidenceNone, wantMigrated: false}, + { + name: "dry-run report is not evidence", + report: &MigrationReportInfo{RunID: "r", Mode: "dry-run", Scope: MigrationReportScope{Mail: true, Files: true, Databases: true}, ExitStatus: "success"}, + wantEvidence: EvidenceNone, wantMigrated: false, wantWarning: "not an apply run", + }, + { + name: "failed apply is not evidence", + report: &MigrationReportInfo{RunID: "r", Mode: "apply", Scope: MigrationReportScope{Mail: true, Files: true, Databases: true}, ExitStatus: "failed"}, + wantEvidence: EvidenceNone, wantMigrated: false, wantWarning: "did not succeed", + }, + {name: "successful apply", report: chkApplyReport(), wantEvidence: EvidenceRunLevel, wantMigrated: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := BuildChecklist(chkInput(src, dest, nil, tt.report)) + for _, name := range []string{"mailboxes", "databases", "web_files"} { + s := chkSection(t, c, name) + if s.MigratedByTool != tt.wantMigrated || s.MigrationEvidence != tt.wantEvidence { + t.Errorf("%s evidence = (%v, %q), want (%v, %q)", + name, s.MigratedByTool, s.MigrationEvidence, tt.wantMigrated, tt.wantEvidence) + } + } + if tt.wantWarning != "" { + found := false + for _, w := range c.Warnings { + if strings.Contains(w, tt.wantWarning) { + found = true + } + } + if !found { + t.Errorf("warnings = %v, want one containing %q", c.Warnings, tt.wantWarning) + } + } + }) + } +} + +func TestBuildChecklistEvidenceRespectsScope(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + rep := &MigrationReportInfo{ + RunID: "r", Mode: "apply", + Scope: MigrationReportScope{Mail: true}, // mail only + ExitStatus: "success", + } + + c := BuildChecklist(chkInput(src, dest, nil, rep)) + + if s := chkSection(t, c, "mailboxes"); !s.MigratedByTool || s.MigrationEvidence != EvidenceRunLevel { + t.Errorf("mailboxes = (%v, %q), want (true, run_level)", s.MigratedByTool, s.MigrationEvidence) + } + for _, name := range []string{"databases", "web_files"} { + if s := chkSection(t, c, name); s.MigratedByTool || s.MigrationEvidence != EvidenceNone { + t.Errorf("%s = (%v, %q), want (false, none): out of the run's scope", name, s.MigratedByTool, s.MigrationEvidence) + } + } + // Sections the tool has no importer for must NEVER claim migration. + for _, name := range []string{"cron", "dns", "ssl", "ftp", "forwarders", "php"} { + if s := chkSection(t, c, name); s.MigratedByTool || s.MigrationEvidence != EvidenceNone { + t.Errorf("%s = (%v, %q), want (false, none): no importer exists", name, s.MigratedByTool, s.MigrationEvidence) + } + } +} + +// TestBuildChecklistEvidencePerItemWithApplyPhases pins the PR 7C upgrade: a +// successful apply report whose phases_completed proves BOTH the migrate and +// the verify phase of a flow raises that section's evidence to per_item (the +// verify phases are per-item integrity passes whose failures gate the exit +// status). Domains have no verify phase; create_domains alone suffices. +func TestBuildChecklistEvidencePerItemWithApplyPhases(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReportWithPhases(chkAllApplyPhases...))) + + for _, name := range []string{"domains", "mailboxes", "web_files", "databases"} { + s := chkSection(t, c, name) + if !s.MigratedByTool || s.MigrationEvidence != EvidencePerItem { + t.Errorf("%s = (%v, %q), want (true, per_item)", name, s.MigratedByTool, s.MigrationEvidence) + } + } +} + +// TestBuildChecklistEvidencePartialPhasesStayRunLevel: a flow whose verify +// phase is missing from phases_completed keeps run_level - completing the +// migrate phase alone does not prove per-item verification. +func TestBuildChecklistEvidencePartialPhasesStayRunLevel(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + + // verify_mail and verify_db are missing; the file flow is complete. + c := BuildChecklist(chkInput(src, dest, nil, + chkApplyReportWithPhases("create_domains", "migrate_mail", "copy_files", "verify_files", "migrate_db"))) + + for name, want := range map[string]string{ + "domains": EvidencePerItem, + "mailboxes": EvidenceRunLevel, + "web_files": EvidencePerItem, + "databases": EvidenceRunLevel, + } { + s := chkSection(t, c, name) + if !s.MigratedByTool || s.MigrationEvidence != want { + t.Errorf("%s = (%v, %q), want (true, %q)", name, s.MigratedByTool, s.MigrationEvidence, want) + } + } +} + +// TestBuildChecklistEvidenceLegacyReportStaysRunLevel: a pre-7C report.json +// has no phases_completed at all - evidence stays run_level, never per_item. +func TestBuildChecklistEvidenceLegacyReportStaysRunLevel(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + for _, name := range []string{"domains", "mailboxes", "web_files", "databases"} { + s := chkSection(t, c, name) + if !s.MigratedByTool || s.MigrationEvidence != EvidenceRunLevel { + t.Errorf("%s = (%v, %q), want (true, run_level)", name, s.MigratedByTool, s.MigrationEvidence) + } + } +} + +// TestBuildChecklistEvidencePhasesNeverOverrideFailedExit: phases_completed +// on a FAILED apply run must not produce any evidence at all - the exit +// status gate comes first. +func TestBuildChecklistEvidencePhasesNeverOverrideFailedExit(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + rep := chkApplyReportWithPhases(chkAllApplyPhases...) + rep.ExitStatus = "failed" + + c := BuildChecklist(chkInput(src, dest, nil, rep)) + + for _, name := range []string{"domains", "mailboxes", "web_files", "databases"} { + s := chkSection(t, c, name) + if s.MigratedByTool || s.MigrationEvidence != EvidenceNone { + t.Errorf("%s = (%v, %q), want (false, none): failed run, phases are irrelevant", name, s.MigratedByTool, s.MigrationEvidence) + } + } +} + +// --------------------------------------------------------------------------- +// Expected differences +// --------------------------------------------------------------------------- + +func TestBuildChecklistDocrootDifferenceIsExpected(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Domains = []DomainEntry{{Name: "main.example", Type: "main", DocumentRoot: "/home/other/public_html"}} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + dom := chkSection(t, c, "domains") + if dom.Status != SectionExpectedDifference { + t.Errorf("domains status = %q, want %q", dom.Status, SectionExpectedDifference) + } + if len(dom.ExpectedDifferences) != 1 { + t.Fatalf("domains expected differences = %d, want 1 (%+v)", len(dom.ExpectedDifferences), dom.ExpectedDifferences) + } +} + +func TestBuildChecklistSOADifferenceIsExpected(t *testing.T) { + // soaRec (defined in dnsplan_test.go, same package) builds the SOA + // record so this file stays free of the read-client import the + // checklist safety test forbids. + src := chkInventory("source", "192.0.2.1", "srcacct") + src.DNS.Zones = []DNSZoneResult{planZone("main.example", soaRec("main.example.", "ns1.host. root.host. 2026010101", 86400), aRec("main.example.", "192.0.2.1", 300))} + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.DNS.Zones = []DNSZoneResult{planZone("main.example", soaRec("main.example.", "ns1.host. root.host. 2026070201", 86400), aRec("main.example.", "192.0.2.1", 300))} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + dns := chkSection(t, c, "dns") + if dns.Status != SectionExpectedDifference { + t.Errorf("dns status = %q, want %q", dns.Status, SectionExpectedDifference) + } + if len(dns.ExpectedDifferences) != 1 || !strings.Contains(dns.ExpectedDifferences[0].Key, "SOA") { + t.Errorf("dns expected differences = %+v, want the SOA change", dns.ExpectedDifferences) + } +} + +func TestBuildChecklistARecordCoveredByPlanSkipIsExpected(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + // Destination apex A already points at the NEW address. + dest.DNS.Zones = []DNSZoneResult{planZone("main.example", + aRec("main.example.", "192.0.2.2", 300), + mxRec("main.example.", "main.example.", 0, 300), + )} + + plan, err := BuildDNSPlan(src, dest, nil, map[string]string{"192.0.2.1": "192.0.2.2"}) + if err != nil { + t.Fatal(err) + } + + // Without the plan the A change is an ordinary review. + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + if dns := chkSection(t, c, "dns"); dns.Status != SectionReviewRequired { + t.Errorf("dns status without plan = %q, want %q", dns.Status, SectionReviewRequired) + } + + // With the plan proving the destination ALREADY matches the desired + // translation (action=skip), the change is expected. + c = BuildChecklist(chkInput(src, dest, &plan, chkApplyReport())) + dns := chkSection(t, c, "dns") + if dns.Status != SectionExpectedDifference { + t.Errorf("dns status with plan = %q, want %q", dns.Status, SectionExpectedDifference) + } + if len(dns.ExpectedDifferences) == 0 { + t.Error("dns expected differences empty, want the A rrset covered by plan skip") + } +} + +func TestBuildChecklistPendingPlanWorkIsNotExpected(t *testing.T) { + // Destination zone exists but has no apex A record: the plan will say + // "add" - that is PENDING work, not an expected difference. + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.DNS.Zones = []DNSZoneResult{planZone("main.example", + mxRec("main.example.", "main.example.", 0, 300), + )} + + plan, err := BuildDNSPlan(src, dest, nil, map[string]string{"192.0.2.1": "192.0.2.2"}) + if err != nil { + t.Fatal(err) + } + c := BuildChecklist(chkInput(src, dest, &plan, chkApplyReport())) + + dns := chkSection(t, c, "dns") + if dns.Status == SectionExpectedDifference || dns.Status == SectionOK { + t.Errorf("dns status = %q: a pending plan add must not read as expected/ok", dns.Status) + } +} + +func TestBuildChecklistSSLChangedButValidIsExpected(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.SSL.Items = []SSLEntry{{ + Domains: "main.example", Issuer: "E1 (reissued)", ValidFrom: 1_790_000_000, + ValidUntil: chkCertValidUntil, ValidationType: "dv", + }} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + ssl := chkSection(t, c, "ssl") + if ssl.Status != SectionExpectedDifference { + t.Errorf("ssl status = %q, want %q", ssl.Status, SectionExpectedDifference) + } + if len(ssl.ExpectedDifferences) == 0 { + t.Error("ssl expected differences empty, want the reissued-but-valid certificate") + } + if acts := chkActionsOf(c, "ssl", MActionAcceptExpectedDiff); len(acts) == 0 { + t.Error("want a non-blocking ACCEPT_EXPECTED_DIFFERENCE acknowledgment for the reissued certificate") + } else { + for _, a := range acts { + if a.BlockingCutover { + t.Error("ACCEPT_EXPECTED_DIFFERENCE must never block the cutover") + } + } + } +} + +func TestBuildChecklistSSLChangedAndExpiredStaysReview(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.SSL.Items = []SSLEntry{{ + Domains: "main.example", Issuer: "E1", ValidFrom: 1_600_000_000, + ValidUntil: chkCertExpiredUntil, ValidationType: "dv", + }} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + ssl := chkSection(t, c, "ssl") + if ssl.Status == SectionExpectedDifference || ssl.Status == SectionOK { + t.Errorf("ssl status = %q: an EXPIRED destination certificate is never an expected difference", ssl.Status) + } + if acts := chkActionsOf(c, "ssl", MActionReissueSSL); len(acts) == 0 { + t.Error("want a REISSUE_SSL action for the expired destination certificate") + } +} + +func TestBuildChecklistSSLRemovedButCoveredIsExpected(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + // Destination regrouped the SANs: the source cert key disappears, but + // every domain it covered is still covered by a valid cert. + dest.SSL.Items = []SSLEntry{{ + Domains: "main.example,www.main.example", Issuer: "E1", ValidFrom: 1_790_000_000, + ValidUntil: chkCertValidUntil, ValidationType: "dv", + }} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + ssl := chkSection(t, c, "ssl") + if ssl.Status == SectionBlocked { + t.Errorf("ssl status = %q: removed cert fully covered by a valid destination cert must not block", ssl.Status) + } + if len(ssl.ExpectedDifferences) == 0 { + t.Error("ssl expected differences empty, want the regrouped-but-covered certificate") + } +} + +func TestBuildChecklistSSLRemovedUncoveredStaysBlocked(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.SSL.Items = []SSLEntry{} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + ssl := chkSection(t, c, "ssl") + if ssl.Status != SectionBlocked { + t.Errorf("ssl status = %q, want %q", ssl.Status, SectionBlocked) + } + if acts := chkActionsOf(c, "ssl", MActionReissueSSL); len(acts) == 0 { + t.Error("want a blocking REISSUE_SSL action for the missing certificate") + } + if c.OverallStatus != OverallBlocked { + t.Errorf("overall = %q, want %q", c.OverallStatus, OverallBlocked) + } +} + +// Real-smoke finding 2 (PR7A_REAL_SMOKE.md): old wildcard certificate +// generations already EXPIRED on the source must not gate the cutover when +// their grouping is missing on the destination - there is nothing valid to +// migrate. +func TestBuildChecklistSSLRemovedExpiredOnSourceIsExpected(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + // Two expired wildcard generations share the same diff key; the + // destination never regains a wildcard cert (AutoSSL per-vhost only). + src.SSL.Items = append(src.SSL.Items, + SSLEntry{Domains: "*.main.example,main.example", Issuer: "R3 gen1", + ValidFrom: 1_500_000_000, ValidUntil: chkCertExpiredUntil, ValidationType: "dv"}, + SSLEntry{Domains: "*.main.example,main.example", Issuer: "R3 gen2", + ValidFrom: 1_600_000_000, ValidUntil: chkCertExpiredUntil, ValidationType: "dv"}, + ) + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + ssl := chkSection(t, c, "ssl") + if ssl.Status == SectionBlocked { + t.Errorf("ssl status = %q: a source-expired certificate group must not block the cutover", ssl.Status) + } + found := false + for _, d := range ssl.ExpectedDifferences { + if d.Key == "*.main.example,main.example" { + found = true + } + } + if !found { + t.Errorf("expected differences %v: want the expired source certificate group", ssl.ExpectedDifferences) + } + if acts := chkActionsOf(c, "ssl", MActionAcceptExpectedDiff); len(acts) == 0 { + t.Error("want a non-blocking ACCEPT_EXPECTED_DIFFERENCE acknowledgment for the expired source certificate") + } else { + for _, a := range acts { + if a.BlockingCutover { + t.Error("ACCEPT_EXPECTED_DIFFERENCE must never block the cutover") + } + } + } + if acts := chkActionsOf(c, "ssl", MActionReissueSSL); len(acts) != 0 { + t.Errorf("got %d REISSUE_SSL actions, want none: nothing valid was lost", len(acts)) + } +} + +// Fail-safe: if ANY generation in the removed group is still valid on the +// source, the group is live and its loss keeps blocking. +func TestBuildChecklistSSLRemovedGroupWithValidGenerationStaysBlocked(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + src.SSL.Items = append(src.SSL.Items, + SSLEntry{Domains: "*.main.example,main.example", Issuer: "R3 gen1", + ValidFrom: 1_500_000_000, ValidUntil: chkCertExpiredUntil, ValidationType: "dv"}, + SSLEntry{Domains: "*.main.example,main.example", Issuer: "R3 gen2", + ValidFrom: 1_700_000_000, ValidUntil: chkCertValidUntil, ValidationType: "dv"}, + ) + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + ssl := chkSection(t, c, "ssl") + if ssl.Status != SectionBlocked { + t.Errorf("ssl status = %q, want %q: one generation is still valid on the source", ssl.Status, SectionBlocked) + } + if acts := chkActionsOf(c, "ssl", MActionReissueSSL); len(acts) == 0 { + t.Error("want a blocking REISSUE_SSL action: a still-valid wildcard certificate was lost") + } +} + +// Fail-safe: an unknown expiry (ValidUntil == 0) is NOT proof of expiry. +func TestBuildChecklistSSLRemovedUnknownExpiryStaysBlocked(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + src.SSL.Items = append(src.SSL.Items, + SSLEntry{Domains: "*.main.example,main.example", Issuer: "R3", + ValidFrom: 1_500_000_000, ValidUntil: 0, ValidationType: "dv"}, + ) + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + ssl := chkSection(t, c, "ssl") + if ssl.Status != SectionBlocked { + t.Errorf("ssl status = %q, want %q: unknown expiry must stay fail-safe", ssl.Status, SectionBlocked) + } +} + +// Fail-safe: a source certificate with NO domain list surfaces as the +// "(no domain list)" placeholder ref, which can never match a real Domains +// key - even when that certificate is itself expired, the removal must keep +// blocking (the tool cannot prove which domains it covered). +func TestBuildChecklistSSLNoDomainListExpiredStaysBlocked(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + src.SSL.Items = append(src.SSL.Items, + SSLEntry{Domains: "", Issuer: "R3", + ValidFrom: 1_500_000_000, ValidUntil: chkCertExpiredUntil, ValidationType: "dv"}, + ) + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + ssl := chkSection(t, c, "ssl") + if ssl.Status != SectionBlocked { + t.Errorf("ssl status = %q, want %q: an unmatchable placeholder key must never downgrade", ssl.Status, SectionBlocked) + } + if acts := chkActionsOf(c, "ssl", MActionReissueSSL); len(acts) == 0 { + t.Error("want a blocking REISSUE_SSL action for the certificate without a domain list") + } +} + +// Fail-safe: one expired generation followed by one with UNKNOWN expiry +// under the same key - the unknown entry must veto the downgrade even after +// an expired entry was already seen. +func TestBuildChecklistSSLRemovedExpiredThenUnknownStaysBlocked(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + src.SSL.Items = append(src.SSL.Items, + SSLEntry{Domains: "*.main.example,main.example", Issuer: "R3 gen1", + ValidFrom: 1_500_000_000, ValidUntil: chkCertExpiredUntil, ValidationType: "dv"}, + SSLEntry{Domains: "*.main.example,main.example", Issuer: "R3 gen2", + ValidFrom: 1_600_000_000, ValidUntil: 0, ValidationType: "dv"}, + ) + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + ssl := chkSection(t, c, "ssl") + if ssl.Status != SectionBlocked { + t.Errorf("ssl status = %q, want %q: an unknown-expiry generation must veto the expired-group downgrade", ssl.Status, SectionBlocked) + } +} + +func TestCertDomainCovers(t *testing.T) { + cases := []struct { + certDom, dom string + want bool + }{ + {"main.example", "main.example", true}, // exact + {"*.main.example", "shop.main.example", true}, // one extra label + {"*.main.example", "main.example", false}, // never the base itself + {"*.main.example", "a.b.main.example", false}, // never multi-label + {"*.main.example", "*.main.example", true}, // literal wildcard match + {"*.main.example", "*.other.example", false}, // wildcard query, other zone + {"*.", "x", false}, // bare wildcard, empty base + {"*.a.b", "*.c.a.b", false}, // wildcard query never synthesized + {"shop.main.example", "*.main.example", false}, // per-host never covers a wildcard + {"*.main.example", ".main.example", false}, // empty label + {"", "", true}, // pre-existing exact-match semantics + {"", "main.example", false}, + } + for _, tc := range cases { + if got := certDomainCovers(tc.certDom, tc.dom); got != tc.want { + t.Errorf("certDomainCovers(%q, %q) = %v, want %v", tc.certDom, tc.dom, got, tc.want) + } + } +} + +// Semantic wildcard coverage: a valid destination wildcard covers the +// single-label subdomain a removed per-host certificate used to serve. +func TestBuildChecklistSSLRemovedCoveredByWildcardIsExpected(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + src.SSL.Items = append(src.SSL.Items, + SSLEntry{Domains: "shop.main.example", Issuer: "R3", + ValidFrom: 1_700_000_000, ValidUntil: chkCertValidUntil, ValidationType: "dv"}, + ) + dest.SSL.Items = append(dest.SSL.Items, + SSLEntry{Domains: "*.main.example", Issuer: "E1", + ValidFrom: 1_790_000_000, ValidUntil: chkCertValidUntil, ValidationType: "dv"}, + ) + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + ssl := chkSection(t, c, "ssl") + if ssl.Status == SectionBlocked { + t.Errorf("ssl status = %q: shop.main.example is covered by the valid destination wildcard", ssl.Status) + } + found := false + for _, d := range ssl.ExpectedDifferences { + if d.Key == "shop.main.example" { + found = true + } + } + if !found { + t.Errorf("expected differences %v: want the wildcard-covered certificate", ssl.ExpectedDifferences) + } +} + +// Wildcard coverage limits (fail-safe): *.base never covers the base domain +// itself, multi-label subdomains, or anything when the wildcard is expired; +// a lost wildcard is never covered by per-host certificates. +func TestBuildChecklistSSLWildcardCoverageLimits(t *testing.T) { + cases := []struct { + name string + srcCert SSLEntry + dstCert SSLEntry + }{ + { + name: "base domain not covered by wildcard", + srcCert: SSLEntry{Domains: "other.example", Issuer: "R3", + ValidFrom: 1_700_000_000, ValidUntil: chkCertValidUntil, ValidationType: "dv"}, + dstCert: SSLEntry{Domains: "*.other.example", Issuer: "E1", + ValidFrom: 1_790_000_000, ValidUntil: chkCertValidUntil, ValidationType: "dv"}, + }, + { + name: "multi-label subdomain not covered by wildcard", + srcCert: SSLEntry{Domains: "a.b.main.example", Issuer: "R3", + ValidFrom: 1_700_000_000, ValidUntil: chkCertValidUntil, ValidationType: "dv"}, + dstCert: SSLEntry{Domains: "*.main.example", Issuer: "E1", + ValidFrom: 1_790_000_000, ValidUntil: chkCertValidUntil, ValidationType: "dv"}, + }, + { + name: "expired destination wildcard covers nothing", + srcCert: SSLEntry{Domains: "shop.main.example", Issuer: "R3", + ValidFrom: 1_700_000_000, ValidUntil: chkCertValidUntil, ValidationType: "dv"}, + dstCert: SSLEntry{Domains: "*.main.example", Issuer: "E1", + ValidFrom: 1_500_000_000, ValidUntil: chkCertExpiredUntil, ValidationType: "dv"}, + }, + { + name: "valid wildcard never covered by per-host certificates", + srcCert: SSLEntry{Domains: "*.main.example", Issuer: "R3", + ValidFrom: 1_700_000_000, ValidUntil: chkCertValidUntil, ValidationType: "dv"}, + dstCert: SSLEntry{Domains: "shop.main.example", Issuer: "E1", + ValidFrom: 1_790_000_000, ValidUntil: chkCertValidUntil, ValidationType: "dv"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + src.SSL.Items = append(src.SSL.Items, tc.srcCert) + dest.SSL.Items = append(dest.SSL.Items, tc.dstCert) + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + ssl := chkSection(t, c, "ssl") + if ssl.Status != SectionBlocked { + t.Errorf("ssl status = %q, want %q", ssl.Status, SectionBlocked) + } + if acts := chkActionsOf(c, "ssl", MActionReissueSSL); len(acts) == 0 { + t.Error("want a blocking REISSUE_SSL action for the uncovered certificate") + } + }) + } +} + +// --------------------------------------------------------------------------- +// Manual actions taxonomy +// --------------------------------------------------------------------------- + +func TestBuildChecklistCronActions(t *testing.T) { + tests := []struct { + name string + job CronJobEntry + wantType string + wantBlocking bool + }{ + { + name: "enabled job with home path", + job: CronJobEntry{Type: "standard", Minute: "5", Hour: "1", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "php /home/acct/cronjob.php", CommandSHA256: "sha256:c1", RawLineSHA256: "sha256:r1", + Enabled: true, LineNumber: 1, Warnings: []string{}}, + wantType: MActionAdaptCronPath, wantBlocking: true, + }, + { + name: "enabled job without home path", + job: CronJobEntry{Type: "standard", Minute: "5", Hour: "1", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "curl -fsS https://example.com/ping", CommandSHA256: "sha256:c2", RawLineSHA256: "sha256:r2", + Enabled: true, LineNumber: 1, Warnings: []string{}}, + wantType: MActionRecreateCron, wantBlocking: true, + }, + { + name: "disabled job", + job: CronJobEntry{Type: "standard", Minute: "5", Hour: "1", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "php /home/acct/old.php", CommandSHA256: "sha256:c3", RawLineSHA256: "sha256:r3", + Enabled: false, LineNumber: 1, Warnings: []string{}}, + wantType: MActionRecreateCron, wantBlocking: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + src.Cron.Jobs = []CronJobEntry{tt.job} + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Cron.Jobs = []CronJobEntry{} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + acts := chkActionsOf(c, "cron", tt.wantType) + if len(acts) != 1 { + t.Fatalf("cron %s actions = %d, want 1 (all: %+v)", tt.wantType, len(acts), c.ManualActions) + } + if acts[0].BlockingCutover != tt.wantBlocking { + t.Errorf("blocking = %v, want %v", acts[0].BlockingCutover, tt.wantBlocking) + } + }) + } +} + +func TestBuildChecklistMXChangeYieldsConfirmMXExternal(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.DNS.Zones = []DNSZoneResult{planZone("main.example", + aRec("main.example.", "192.0.2.1", 300), + mxRec("main.example.", "other-host.example.", 10, 300), + )} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + dns := chkSection(t, c, "dns") + if dns.Status != SectionBlocked { + t.Errorf("dns status = %q, want %q (MX changed is a policy blocker)", dns.Status, SectionBlocked) + } + if acts := chkActionsOf(c, "dns", MActionConfirmMXExternal); len(acts) != 1 || !acts[0].BlockingCutover { + t.Errorf("CONFIRM_MX_EXTERNAL = %+v, want exactly 1 blocking", acts) + } + if c.OverallStatus != OverallBlocked { + t.Errorf("overall = %q, want %q", c.OverallStatus, OverallBlocked) + } +} + +func TestBuildChecklistDNSPlanManualOps(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + src.DNS.Zones = []DNSZoneResult{planZone("main.example", + aRec("main.example.", "192.0.2.1", 300), + txtRec("main.example.", "v=spf1 +a +mx +ip4:192.0.2.1 ~all", 300), + )} + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.DNS.Zones = []DNSZoneResult{planZone("main.example", + aRec("main.example.", "192.0.2.2", 300), + )} + + plan, err := BuildDNSPlan(src, dest, nil, map[string]string{"192.0.2.1": "192.0.2.2"}) + if err != nil { + t.Fatal(err) + } + c := BuildChecklist(chkInput(src, dest, &plan, chkApplyReport())) + + if acts := chkActionsOf(c, "dns", MActionUpdateSPF); len(acts) != 1 || !acts[0].BlockingCutover { + t.Errorf("UPDATE_SPF = %+v, want exactly 1 blocking (SPF carries the mapped source address)", acts) + } +} + +func TestBuildChecklistForwarderRemovedBlocksViaAction(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Forwarders = []NormForwarderEntry{} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + fw := chkSection(t, c, "forwarders") + if fw.Status != SectionNotMigratedByTool { + t.Errorf("forwarders status = %q, want %q (source-only data, no importer)", fw.Status, SectionNotMigratedByTool) + } + if acts := chkActionsOf(c, "forwarders", MActionCreateOnDestination); len(acts) != 1 || !acts[0].BlockingCutover { + t.Errorf("forwarder CREATE_ON_DESTINATION = %+v, want exactly 1 blocking (mail flow)", acts) + } + if c.OverallStatus != OverallManualActionRequired { + t.Errorf("overall = %q, want %q", c.OverallStatus, OverallManualActionRequired) + } +} + +func TestBuildChecklistPHPChangeYieldsCompatCheck(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.PHP.Items = []PHPEntry{{Domain: "main.example", Version: "ea-php82"}} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + if acts := chkActionsOf(c, "php", MActionCheckPHPCompat); len(acts) != 1 || acts[0].BlockingCutover { + t.Errorf("CHECK_PHP_COMPATIBILITY = %+v, want exactly 1 non-blocking", acts) + } +} + +// --------------------------------------------------------------------------- +// Synthetic sections, structure, determinism +// --------------------------------------------------------------------------- + +func TestBuildChecklistFormerSyntheticSectionsInventoried(t *testing.T) { + // PR 7E: the four former not_inventoried areas are real sections. + // With identical data on both sides they resolve like any other + // section - NO blanket manual checks survive. + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + want := map[string]string{ + "email_routing": SectionOK, + "default_address": SectionOK, + "email_filters": SectionNotApplicable, // zero filters on both sides + "redirects": SectionNotApplicable, // zero redirects on both sides + "quota_package": SectionNotAccessibleWithoutRoot, + "server_level_config": SectionNotAccessibleWithoutRoot, + } + for name, status := range want { + if s := chkSection(t, c, name); s.Status != status { + t.Errorf("%s status = %q, want %q", name, s.Status, status) + } + } + for _, sec := range []string{"email_routing", "default_address", "email_filters", "redirects"} { + if acts := chkActionsOf(c, sec, MActionManualCheckRequired); len(acts) != 0 { + t.Errorf("%s: blanket MANUAL_CHECK_REQUIRED survived: %+v", sec, acts) + } + } + if c.Summary.NotInventoried != 0 { + t.Errorf("summary.not_inventoried = %d, want 0", c.Summary.NotInventoried) + } +} + +func TestBuildChecklistAccountAndModeFields(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "destacct") + + c := BuildChecklist(chkInput(src, dest, nil, nil)) + + if c.Mode != "migration-checklist" || c.FormatVersion != 1 { + t.Errorf("mode/version = %q/%d, want migration-checklist/1", c.Mode, c.FormatVersion) + } + if c.Account != "srcacct" { + t.Errorf("account = %q, want the SOURCE account user", c.Account) + } + if c.ChainVerified { + t.Error("chain_verified must be false until diff/policy record their own input hashes (PR 7B)") + } +} + +func TestBuildChecklistDeterministicAndConsistent(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Cron.Jobs = []CronJobEntry{} + dest.PHP.Items = []PHPEntry{{Domain: "main.example", Version: "ea-php82"}} + in := chkInput(src, dest, nil, chkApplyReport()) + + a := BuildChecklist(in) + b := BuildChecklist(in) + if !reflect.DeepEqual(a, b) { + t.Fatal("BuildChecklist is not deterministic: two runs differ") + } + ja, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + jb, err := json.Marshal(b) + if err != nil { + t.Fatal(err) + } + if string(ja) != string(jb) { + t.Fatal("JSON output differs between two identical runs") + } + + // Action IDs are unique and cross-referenced both ways. + ids := map[string]string{} + for _, act := range a.ManualActions { + if act.ID == "" { + t.Errorf("action without ID: %+v", act) + } + if prev, dup := ids[act.ID]; dup { + t.Errorf("duplicate action ID %s (%s and %s)", act.ID, prev, act.Section) + } + ids[act.ID] = act.Section + } + for _, s := range a.Sections { + for _, ref := range s.ManualActionRefs { + if ids[ref] != s.Section { + t.Errorf("section %s references action %s owned by %q", s.Section, ref, ids[ref]) + } + } + } + refd := map[string]bool{} + for _, s := range a.Sections { + for _, ref := range s.ManualActionRefs { + refd[ref] = true + } + } + for id := range ids { + if !refd[id] { + t.Errorf("action %s not referenced by any section", id) + } + } + + // Summary consistency. + if a.Summary.ManualActions != len(a.ManualActions) { + t.Errorf("summary.manual_actions = %d, want %d", a.Summary.ManualActions, len(a.ManualActions)) + } + if a.Summary.Accepted != 0 { + t.Errorf("summary.accepted = %d, want 0 (no acceptance file was provided)", a.Summary.Accepted) + } +} + +func TestBuildChecklistJSONHasNoNullArrays(t *testing.T) { + src := chkEmptyInventory("source", "192.0.2.1", "srcacct") + dest := chkEmptyInventory("destination", "192.0.2.2", "srcacct") + + c := BuildChecklist(chkInput(src, dest, nil, nil)) + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), ": null") { + t.Errorf("checklist JSON contains null arrays/objects:\n%s", b) + } +} + +func TestBuildChecklistSectionUnavailableGates(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + src.Cron = NewEmptyCronSection() // unavailable on source + dest := chkInventory("destination", "192.0.2.2", "srcacct") + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + cron := chkSection(t, c, "cron") + if cron.Status != SectionReviewRequired { + t.Errorf("cron status = %q, want %q (skipped comparison can never be ok)", cron.Status, SectionReviewRequired) + } +} + +// --------------------------------------------------------------------------- +// Operator acceptances (PR 7D) +// --------------------------------------------------------------------------- + +func chkAcceptance(key string) OperatorAcceptance { + return OperatorAcceptance{ + ActionKey: key, Reason: "reviewed with the customer", + AcceptedBy: "reviewer", AcceptedAt: "2026-07-02T10:00:00Z", + } +} + +func chkInputWithAcceptances(src, dest NormalizedInventory, rep *MigrationReportInfo, accs []OperatorAcceptance) ChecklistInput { + in := chkInput(src, dest, nil, rep) + in.Acceptances = accs + return in +} + +// chkDivergeMailConfig makes the destination diverge on the three 7E +// mail areas, producing exactly three ACCEPTABLE blocking actions +// (CONFIRM_EMAIL_ROUTING, MANUAL_CHECK_REQUIRED on the default address, +// RECREATE_EMAIL_FILTERS) - the acceptance tests' baseline since the +// blanket not_inventoried actions no longer exist. +func chkDivergeMailConfig(src, dest *NormalizedInventory) { + dest.EmailRouting.Items[0].Routing = "remote" + dest.DefaultAddresses.Items[0].DefaultAddress = "info@main.example" + src.EmailFilters.Items = []NormEmailFilterEntry{{ + Account: "", FilterName: "spam-to-junk", Enabled: true, RuleCount: 1, ActionCount: 1, + }} +} + +// TestManualActionKeysStableAndUnique pins the acceptance handle: every +// action carries a content-derived key (AK-<12 hex>) that is IDENTICAL +// across regenerations from the same inputs and unique per action, while +// the positional MA-nnn id may shift when findings change. +func TestManualActionKeysStableAndUnique(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + chkDivergeMailConfig(&src, &dest) + + c1 := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + c2 := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + + if len(c1.ManualActions) == 0 { + t.Fatal("want the three divergence-driven actions") + } + keyRe := regexp.MustCompile(`^AK-[0-9a-f]{12}$`) + seen := map[string]bool{} + for i, a := range c1.ManualActions { + if !keyRe.MatchString(a.Key) { + t.Errorf("action %s key = %q, want AK-<12 hex>", a.ID, a.Key) + } + if seen[a.Key] { + t.Errorf("duplicate action key %q", a.Key) + } + seen[a.Key] = true + if a.Key != c2.ManualActions[i].Key { + t.Errorf("action %s key changed across identical regenerations: %q vs %q", a.ID, a.Key, c2.ManualActions[i].Key) + } + } +} + +// TestBuildChecklistAcceptanceClearsManualGate: accepting every blocking +// (and acceptable) action moves the verdict from MANUAL_ACTION_REQUIRED to +// READY_WITH_MANUAL_NOTES, populates accepted_by_operator on the owning +// sections and summary.accepted - while the underlying policy reviews KEEP +// gating the section status. +func TestBuildChecklistAcceptanceClearsManualGate(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + chkDivergeMailConfig(&src, &dest) + + base := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + if base.OverallStatus != OverallManualActionRequired { + t.Fatalf("baseline overall = %q, want %q", base.OverallStatus, OverallManualActionRequired) + } + var accs []OperatorAcceptance + for _, a := range base.ManualActions { + if a.BlockingCutover { + if !a.Acceptable { + t.Fatalf("baseline has a non-acceptable blocking action %s (%s) - scenario invalid", a.ID, a.Type) + } + accs = append(accs, chkAcceptance(a.Key)) + } + } + if len(accs) == 0 { + t.Fatal("baseline has no blocking actions to accept - scenario invalid") + } + + c := BuildChecklist(chkInputWithAcceptances(src, dest, chkApplyReport(), accs)) + + if c.OverallStatus != OverallReadyWithManualNotes { + t.Fatalf("overall = %q, want %q after accepting every blocking action", c.OverallStatus, OverallReadyWithManualNotes) + } + if c.Summary.Accepted != len(accs) { + t.Errorf("summary.accepted = %d, want %d", c.Summary.Accepted, len(accs)) + } + er := chkSection(t, c, "email_routing") + if er.Status != SectionReviewRequired { + t.Errorf("email_routing status = %q, want %q: acceptance clears the gate, not the underlying review", er.Status, SectionReviewRequired) + } + if len(er.AcceptedByOperator) != 1 { + t.Errorf("email_routing accepted_by_operator = %v, want the accepted action id", er.AcceptedByOperator) + } + for _, a := range c.ManualActions { + if a.BlockingCutover { + if !a.Accepted || a.AcceptedBy != "reviewer" || a.AcceptedReason == "" || a.AcceptedAt == "" { + t.Errorf("action %s = %+v, want accepted with author/reason/date", a.ID, a) + } + } + } +} + +// TestBuildChecklistAcceptanceNonAcceptableIgnored: BOTH blocking cron +// action types (RECREATE_CRON and ADAPT_CRON_PATH) must be RESOLVED, not +// waved through - acceptances targeting them are ignored with a warning and +// the verdict stays blocked. +func TestBuildChecklistAcceptanceNonAcceptableIgnored(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + // A second enabled job WITHOUT /home/ paths -> plain RECREATE_CRON; the + // fixture job carries /home/acct -> ADAPT_CRON_PATH. + src.Cron.Jobs = append(src.Cron.Jobs, CronJobEntry{ + Type: "standard", Minute: "10", Hour: "2", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "php -q cron.php --token=****", + CommandSHA256: "sha256:ccc", RawLineSHA256: "sha256:ddd", Enabled: true, LineNumber: 2, + Warnings: []string{}, + }) + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Cron.Jobs = []CronJobEntry{} // every source cron job lost + + base := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + keys := map[string]string{} // type -> key + for _, a := range base.ManualActions { + if (a.Type == MActionRecreateCron || a.Type == MActionAdaptCronPath) && a.BlockingCutover { + if a.Acceptable { + t.Fatalf("blocking %s must not be acceptable: %+v", a.Type, a) + } + keys[a.Type] = a.Key + } + } + if keys[MActionRecreateCron] == "" || keys[MActionAdaptCronPath] == "" { + t.Fatalf("baseline actions %v: want one blocking RECREATE_CRON and one blocking ADAPT_CRON_PATH", keys) + } + + c := BuildChecklist(chkInputWithAcceptances(src, dest, chkApplyReport(), + []OperatorAcceptance{chkAcceptance(keys[MActionRecreateCron]), chkAcceptance(keys[MActionAdaptCronPath])})) + + if c.Summary.Accepted != 0 { + t.Errorf("summary.accepted = %d, want 0: non-acceptable actions cannot be accepted", c.Summary.Accepted) + } + if c.OverallStatus != OverallBlocked { + t.Errorf("overall = %q, want %q: the lost cron jobs still gate", c.OverallStatus, OverallBlocked) + } + for typ, key := range keys { + found := false + for _, w := range c.Warnings { + if strings.Contains(w, "not acceptable") && strings.Contains(w, key) { + found = true + } + } + if !found { + t.Errorf("warnings = %v, want one naming the non-acceptable %s key %s", c.Warnings, typ, key) + } + } +} + +// TestBuildChecklistAcceptanceUnknownKeyWarns: an acceptance whose key +// matches nothing (the underlying fact changed since the operator reviewed +// it) is ignored with a warning - stale acceptances self-invalidate. +func TestBuildChecklistAcceptanceUnknownKeyWarns(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + chkDivergeMailConfig(&src, &dest) + + c := BuildChecklist(chkInputWithAcceptances(src, dest, chkApplyReport(), + []OperatorAcceptance{chkAcceptance("AK-000000000000")})) + + if c.Summary.Accepted != 0 { + t.Errorf("summary.accepted = %d, want 0", c.Summary.Accepted) + } + if c.OverallStatus != OverallManualActionRequired { + t.Errorf("overall = %q, want %q: nothing was actually accepted", c.OverallStatus, OverallManualActionRequired) + } + found := false + for _, w := range c.Warnings { + if strings.Contains(w, "AK-000000000000") { + found = true + } + } + if !found { + t.Errorf("warnings = %v, want one naming the unmatched key", c.Warnings) + } +} + +// TestBuildChecklistAcceptanceDuplicateFirstWins: two entries for the same +// key - the first is applied, the duplicate warns. +func TestBuildChecklistAcceptanceDuplicateFirstWins(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + chkDivergeMailConfig(&src, &dest) + + base := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + var key string + for _, a := range base.ManualActions { + if a.Section == "email_routing" { + key = a.Key + } + } + first := chkAcceptance(key) + second := chkAcceptance(key) + second.AcceptedBy = "someone-else" + + c := BuildChecklist(chkInputWithAcceptances(src, dest, chkApplyReport(), []OperatorAcceptance{first, second})) + + if c.Summary.Accepted != 1 { + t.Errorf("summary.accepted = %d, want 1", c.Summary.Accepted) + } + for _, a := range c.ManualActions { + if a.Key == key && a.AcceptedBy != "reviewer" { + t.Errorf("accepted_by = %q, want the FIRST entry's author", a.AcceptedBy) + } + } + found := false + for _, w := range c.Warnings { + if strings.Contains(w, "duplicate") && strings.Contains(w, key) { + found = true + } + } + if !found { + t.Errorf("warnings = %v, want a duplicate-entry warning for %s", c.Warnings, key) + } +} + +// TestBuildChecklistAcceptanceDuplicateIdenticalActionsWarn (reviewer HIGH): +// two structurally identical actions (the same disabled cron job scheduled +// twice, both lost) share the same content key. Only the FIRST is accepted; +// the second must surface a warning instead of silently keeping the gate. +func TestBuildChecklistAcceptanceDuplicateIdenticalActionsWarn(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dup := CronJobEntry{ + Type: "standard", Minute: "5", Hour: "1", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "php /usr/local/bin/report.php --token=****", + CommandSHA256: "sha256:eee", RawLineSHA256: "sha256:fff", Enabled: false, LineNumber: 3, + Warnings: []string{}, + } + dup2 := dup + dup2.LineNumber = 4 + src.Cron.Jobs = append(src.Cron.Jobs, dup, dup2) + dest := chkInventory("destination", "192.0.2.2", "srcacct") + + base := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + var keys []string + for _, a := range base.ManualActions { + if a.Type == MActionRecreateCron && !a.BlockingCutover { + keys = append(keys, a.Key) + } + } + if len(keys) != 2 || keys[0] != keys[1] { + t.Fatalf("want 2 identical non-blocking RECREATE_CRON keys, got %v", keys) + } + + c := BuildChecklist(chkInputWithAcceptances(src, dest, chkApplyReport(), + []OperatorAcceptance{chkAcceptance(keys[0])})) + + if c.Summary.Accepted != 1 { + t.Errorf("summary.accepted = %d, want 1: only the first identical action is accepted", c.Summary.Accepted) + } + found := false + for _, w := range c.Warnings { + if strings.Contains(w, "more than one identical action") && strings.Contains(w, keys[0]) { + found = true + } + } + if !found { + t.Errorf("warnings = %v, want the multi-match warning for key %s", c.Warnings, keys[0]) + } +} + +// TestBuildChecklistAllAcceptedNeverReadyToCutover (reviewer coverage gap): +// accepting EVERY acceptable action can raise the verdict at most to +// READY_WITH_MANUAL_NOTES - an acceptance is a formal note, not an eraser. +func TestBuildChecklistAllAcceptedNeverReadyToCutover(t *testing.T) { + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + chkDivergeMailConfig(&src, &dest) + + base := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + var accs []OperatorAcceptance + for _, a := range base.ManualActions { + if a.Acceptable { + accs = append(accs, chkAcceptance(a.Key)) + } + } + if len(accs) == 0 { + t.Fatal("no acceptable actions in the baseline - scenario invalid") + } + + c := BuildChecklist(chkInputWithAcceptances(src, dest, chkApplyReport(), accs)) + + if c.Summary.Accepted != len(accs) { + t.Fatalf("summary.accepted = %d, want %d", c.Summary.Accepted, len(accs)) + } + if c.OverallStatus != OverallReadyWithManualNotes { + t.Errorf("overall = %q, want %q - never READY_TO_CUTOVER while accepted actions exist", + c.OverallStatus, OverallReadyWithManualNotes) + } +} diff --git a/internal/accountinventory/checklist_types.go b/internal/accountinventory/checklist_types.go new file mode 100644 index 00000000..cc12bbad --- /dev/null +++ b/internal/accountinventory/checklist_types.go @@ -0,0 +1,266 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +// Migration checklist (PR 7A). The checklist is an OFFLINE composition of +// artifacts the pipeline already produces (inventories, diff, policy report, +// optional DNS plan, optional migration report). It answers the operator +// question "what would I forget if I shut the old server down now?" and it +// never connects anywhere, never applies anything, and never claims the tool +// migrated something without evidence. + +import "time" + +// Overall checklist statuses, strongest concern first. +const ( + OverallBlocked = "BLOCKED" + OverallManualActionRequired = "MANUAL_ACTION_REQUIRED" + OverallNotReady = "NOT_READY" + OverallReadyWithManualNotes = "READY_WITH_MANUAL_NOTES" + OverallReadyToCutover = "READY_TO_CUTOVER" +) + +// Per-section checklist statuses. +const ( + SectionOK = "ok" + SectionExpectedDifference = "expected_difference" + SectionManualRequired = "manual_required" + SectionReviewRequired = "review_required" + SectionBlocked = "blocked" + // SectionNotMigratedByTool: the tool has no importer for this area (or + // no proof it ran one) and the source has data the destination lacks. + SectionNotMigratedByTool = "not_migrated_by_tool" + // SectionNotInventoried: the area is account-accessible but the + // inventory has no collector for it yet - distinct from root-only. + SectionNotInventoried = "not_inventoried" + // SectionNotAccessibleWithoutRoot: the area cannot be read at all with + // account-level access (WHM package limits, server config). + SectionNotAccessibleWithoutRoot = "not_accessible_without_root" + SectionNotApplicable = "not_applicable" +) + +// Migration evidence levels. The checklist NEVER sets migrated_by_tool +// without at least run-level evidence; per_item is reserved for the apply +// events work (PR 7C) - nothing produces it yet. +const ( + EvidenceNone = "none" + EvidenceRunLevel = "run_level" + EvidencePerItem = "per_item" +) + +// Manual action types (v0 taxonomy). +const ( + MActionRecreateCron = "RECREATE_CRON" + MActionAdaptCronPath = "ADAPT_CRON_PATH" + MActionConfirmMXExternal = "CONFIRM_MX_EXTERNAL" + MActionConfirmDNSRecord = "CONFIRM_DNS_RECORD" + MActionUpdateSPF = "UPDATE_SPF" + MActionReissueSSL = "REISSUE_SSL" + MActionCheckPHPCompat = "CHECK_PHP_COMPATIBILITY" + MActionCreateOnDestination = "CREATE_ON_DESTINATION" + MActionVerifyExternalSvc = "VERIFY_EXTERNAL_SERVICE" + MActionConfirmEmailRouting = "CONFIRM_EMAIL_ROUTING" + MActionManualCheckRequired = "MANUAL_CHECK_REQUIRED" + // PR 7E: the four former not_inventoried areas became real sections. + MActionRecreateEmailFilters = "RECREATE_EMAIL_FILTERS" + MActionConfirmRedirect = "CONFIRM_REDIRECT" + MActionAcceptExpectedDiff = "ACCEPT_EXPECTED_DIFFERENCE" + // MActionRegeneratePassword is part of the taxonomy but has no + // deterministic trigger in v0 (the inventory carries no password + // state); it is reserved for future evidence-driven generation. + MActionRegeneratePassword = "REGENERATE_PASSWORD" +) + +// ChecklistInputRef records one input artifact: its path, the sha256 of the +// raw bytes as read (stale-input defense, same pattern as the DNS plan), and +// whether it was provided at all. +type ChecklistInputRef struct { + File string `json:"file,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Present bool `json:"present"` +} + +type ChecklistInputs struct { + SourceInventory ChecklistInputRef `json:"source_inventory"` + DestinationInventory ChecklistInputRef `json:"destination_inventory"` + Diff ChecklistInputRef `json:"diff"` + Policy ChecklistInputRef `json:"policy"` + DNSPlan ChecklistInputRef `json:"dns_plan"` + MigrationReport ChecklistInputRef `json:"migration_report"` + // Acceptances records the operator acceptance file reference for the + // audit trail. It is NOT part of the provenance chain verification: an + // acceptance file is operator input, not a derived artifact. + Acceptances ChecklistInputRef `json:"acceptances"` +} + +// ChecklistEvidence is one already-safe pointer shown to the operator (diff +// keys, plan ops, policy refs - never raw commands or secrets, which do not +// exist in the input artifacts to begin with). +type ChecklistEvidence struct { + Kind string `json:"kind"` + Key string `json:"key,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// ExpectedDifference is a difference that is present AND correct (docroot +// layout, regenerated SOA, an A record already translated to the new IP, +// a reissued but valid certificate). +type ExpectedDifference struct { + Key string `json:"key"` + Reason string `json:"reason"` +} + +type ChecklistSection struct { + Section string `json:"section"` + Status string `json:"status"` + MigratedByTool bool `json:"migrated_by_tool"` + MigrationEvidence string `json:"migration_evidence"` + SourcePresent bool `json:"source_present"` + DestinationPresent bool `json:"destination_present"` + SourceCount int `json:"source_count"` + DestinationCount int `json:"destination_count"` + + ExpectedDifferences []ExpectedDifference `json:"expected_differences"` + ManualActionRefs []string `json:"manual_action_refs"` + Blockers []string `json:"blockers"` + BlockersApply []string `json:"blockers_apply"` + BlockersCutover []string `json:"blockers_cutover"` + PolicyFindingRefs []string `json:"policy_finding_refs"` + // AcceptedByOperator lists the ids of this section's actions that an + // operator acceptance matched (PR 7D). + AcceptedByOperator []string `json:"accepted_by_operator"` + PostCutoverChecks []string `json:"post_cutover_checks"` + Evidence []ChecklistEvidence `json:"evidence"` +} + +type ManualAction struct { + ID string `json:"id"` + // Key is the STABLE acceptance handle (PR 7D): a content-derived hash + // (AK-<12 hex> over type/section/title/detail) that survives + // regeneration from the same facts, unlike the positional MA-nnn id. + // When the underlying fact changes the key changes too, so a stale + // acceptance stops matching and the action resurfaces - fail-safe. + Key string `json:"key"` + Type string `json:"type"` + Section string `json:"section"` + BlockingCutover bool `json:"blocking_cutover"` + DerivedFrom []string `json:"derived_from"` + Title string `json:"title"` + Detail string `json:"detail,omitempty"` + Evidence []ChecklistEvidence `json:"evidence"` + OperatorAction string `json:"operator_action"` + Acceptable bool `json:"acceptable"` + // Acceptance state (PR 7D): set when an operator acceptance matched + // this action's key AND the action is acceptable. An accepted action + // stops gating the section status and the overall rollup. + Accepted bool `json:"accepted"` + AcceptedBy string `json:"accepted_by,omitempty"` + AcceptedAt string `json:"accepted_at,omitempty"` + AcceptedReason string `json:"accepted_reason,omitempty"` +} + +// OperatorAcceptance is one entry of the operator acceptance file: a formal, +// attributable decision that a reviewed manual action does not gate the +// cutover. It binds to the action's stable Key, never to the positional id. +type OperatorAcceptance struct { + ActionKey string `json:"action_key"` + ActionID string `json:"action_id,omitempty"` // display only + Reason string `json:"reason"` + AcceptedBy string `json:"accepted_by"` + AcceptedAt string `json:"accepted_at"` +} + +// AcceptanceFile is the on-disk acceptances.json format. ChecklistSHA256 +// records WHICH checklist file the operator reviewed (audit anchor); when +// ChecklistFile is present the CLI verifies the hash strictly and rejects +// the whole file on mismatch. +type AcceptanceFile struct { + Mode string `json:"mode"` + FormatVersion int `json:"format_version"` + ChecklistFile string `json:"checklist_file,omitempty"` + ChecklistSHA256 string `json:"checklist_sha256"` + Acceptances []OperatorAcceptance `json:"acceptances"` +} + +// AcceptanceFileMode is the required mode marker of acceptances.json. +const AcceptanceFileMode = "operator-acceptances" + +// ChecklistSummary counts sections by status, plus totals for expected +// differences (entries, not sections), manual actions, and operator +// acceptances (populated when an acceptance file matches, PR 7D). +type ChecklistSummary struct { + OK int `json:"ok"` + ExpectedDifferences int `json:"expected_differences"` + ManualActions int `json:"manual_actions"` + ReviewRequired int `json:"review_required"` + Blocked int `json:"blocked"` + NotMigratedByTool int `json:"not_migrated_by_tool"` + NotInventoried int `json:"not_inventoried"` + NotAccessibleWithoutRoot int `json:"not_accessible_without_root"` + Accepted int `json:"accepted"` +} + +type MigrationChecklist struct { + Mode string `json:"mode"` + FormatVersion int `json:"format_version"` + Account string `json:"account"` + GeneratedAt string `json:"generated_at"` + Inputs ChecklistInputs `json:"inputs"` + // ChainVerified stays false until diff/policy record the hashes of + // their own inputs (PR 7B): the checklist can hash what it reads, but + // it cannot yet prove the diff was computed FROM these inventories. + ChainVerified bool `json:"chain_verified"` + OverallStatus string `json:"overall_status"` + ApplyBlocked bool `json:"apply_blocked"` + Summary ChecklistSummary `json:"summary"` + Sections []ChecklistSection `json:"sections"` + ManualActions []ManualAction `json:"manual_actions"` + Warnings []string `json:"warnings"` + // CoverageManifest declares EVERY area the tool knows about with its + // coverage state (PR 1A). Purely declarative: it feeds no action, no + // summary count and no verdict - see coverage.go. + CoverageManifest []CoverageArea `json:"coverage_manifest"` +} + +// MigrationReportInfo mirrors the subset of report.json (events.RunReport) +// the checklist consumes. Kept local so the offline engine does not depend +// on the events package. +type MigrationReportScope struct { + Mail bool `json:"mail"` + Files bool `json:"files"` + Databases bool `json:"databases"` +} + +type MigrationReportInfo struct { + RunID string `json:"run_id"` + Mode string `json:"mode"` + Scope MigrationReportScope `json:"scope"` + ExitStatus string `json:"exit_status"` + // PhasesCompleted mirrors report.json's phases_completed (populated by + // apply runs since PR 7C). A pre-7C report decodes it to nil, which + // simply caps evidence at run_level - full backward compatibility. + PhasesCompleted []string `json:"phases_completed"` +} + +// ChecklistInput carries everything BuildChecklist needs. Now is the +// reference time for certificate-validity checks and is injected by the +// caller so the engine stays deterministic (same input, same output). +type ChecklistInput struct { + Source NormalizedInventory + Destination NormalizedInventory + Diff InventoryDiff + Policy PolicyReport + DNSPlan *DNSPlan + MigrationReport *MigrationReportInfo + // Acceptances carries the (already loaded and validated) operator + // acceptance entries; the engine matches them by action key. + Acceptances []OperatorAcceptance + // InputRefs carries the file/sha256 references of every input as the + // caller read them; the engine copies them into the checklist and + // verifies the provenance chain against the hashes the artifacts + // record about their OWN inputs (PR 7B). Zero value -> chain not + // verifiable, chain_verified stays false. + InputRefs ChecklistInputs + Now time.Time +} diff --git a/internal/accountinventory/checklist_write.go b/internal/accountinventory/checklist_write.go new file mode 100644 index 00000000..8ae23909 --- /dev/null +++ b/internal/accountinventory/checklist_write.go @@ -0,0 +1,240 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// WriteChecklistJSON writes the machine-readable checklist (same +// conventions as the other artifacts: pretty-printed, trailing newline, +// 0600). The write is atomic (temp + rename): a reader - e.g. the web ui +// re-reading it on every request - never observes a torn file. +func WriteChecklistJSON(path string, c MigrationChecklist) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + b, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal checklist: %w", err) + } + b = append(b, '\n') + f, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("accountinventory: temp for %s: %w", path, err) + } + tmp := f.Name() + defer func() { _ = os.Remove(tmp) }() // no-op after a successful rename + if _, err := f.Write(b); err != nil { + _ = f.Close() + return fmt.Errorf("accountinventory: write %s: %w", tmp, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("accountinventory: close %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("accountinventory: commit %s: %w", path, err) + } + return nil +} + +// checklistStatusEmoji renders a section status for the operator report. +func checklistStatusEmoji(status string) string { + switch status { + case SectionOK: + return "[OK]" + case SectionExpectedDifference, SectionReviewRequired: + return "[REVIEW]" + case SectionManualRequired, SectionNotMigratedByTool: + return "[MANUAL]" + case SectionBlocked: + return "[BLOCKED]" + case SectionNotInventoried, SectionNotAccessibleWithoutRoot: + return "[WARN]" + default: // not_applicable + return "[NA]" + } +} + +// checklistSectionHeadline builds the one-line summary shown next to each +// section name. +func checklistSectionHeadline(s ChecklistSection) string { + var parts []string + if len(s.Blockers) > 0 { + parts = append(parts, fmt.Sprintf("%d blocker(s)", len(s.Blockers))) + } + if n := len(s.ManualActionRefs); n > 0 { + if acc := len(s.AcceptedByOperator); acc > 0 { + parts = append(parts, fmt.Sprintf("%d manual action(s) (%d accepted)", n, acc)) + } else { + parts = append(parts, fmt.Sprintf("%d manual action(s)", n)) + } + } + if n := len(s.ExpectedDifferences); n > 0 { + parts = append(parts, fmt.Sprintf("%d expected difference(s)", n)) + } + if len(parts) == 0 { + switch s.Status { + case SectionOK: + if s.MigratedByTool { + return fmt.Sprintf("ok - migrated by tool (%s evidence)", s.MigrationEvidence) + } + return "ok" + case SectionReviewRequired: + return "review required" + case SectionNotInventoried: + return "not inventoried - manual check required" + case SectionNotAccessibleWithoutRoot: + return "not accessible without root" + case SectionNotMigratedByTool: + return "not migrated by the tool - no migration evidence" + case SectionNotApplicable: + return "not applicable" + default: + return s.Status + } + } + if s.MigratedByTool { + parts = append(parts, fmt.Sprintf("migrated by tool (%s evidence)", s.MigrationEvidence)) + } + // The coverage gap must stay visible even when the section carries + // actions: "1 manual action" alone would hide WHY the tool cannot see + // this area. + if s.Status == SectionNotInventoried { + parts = append([]string{"not inventoried"}, parts...) + } + return strings.Join(parts, ", ") +} + +// WriteChecklistMarkdown writes the operator-facing report. Every free +// value goes through mdCell so redacted commands and DNS values stay +// table-safe previews; the raw data was already redacted upstream. +func WriteChecklistMarkdown(path string, c MigrationChecklist) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + + fmt.Fprintf(&sb, "# Migration Checklist - %s\n\n", mdCell(c.Account, 60)) + fmt.Fprintf(&sb, "**Overall: %s**\n\n", c.OverallStatus) + fmt.Fprintf(&sb, "- **Generated**: %s\n", c.GeneratedAt) + chain := "no - input hashes missing or mismatched (see warnings)" + if c.ChainVerified { + chain = "yes - inventories -> diff -> policy (-> dns plan) hashes all match" + } + fmt.Fprintf(&sb, "- **Chain verified**: %s\n\n", chain) + + fmt.Fprintf(&sb, "## Summary\n\n") + fmt.Fprintf(&sb, "- OK: %d\n", c.Summary.OK) + fmt.Fprintf(&sb, "- Expected differences: %d\n", c.Summary.ExpectedDifferences) + fmt.Fprintf(&sb, "- Manual actions: %d\n", c.Summary.ManualActions) + fmt.Fprintf(&sb, "- Accepted by operator: %d\n", c.Summary.Accepted) + fmt.Fprintf(&sb, "- Review required: %d\n", c.Summary.ReviewRequired) + fmt.Fprintf(&sb, "- Blocked: %d\n", c.Summary.Blocked) + fmt.Fprintf(&sb, "- Not migrated by tool: %d\n", c.Summary.NotMigratedByTool) + fmt.Fprintf(&sb, "- Not inventoried: %d\n", c.Summary.NotInventoried) + fmt.Fprintf(&sb, "- Not accessible without root: %d\n\n", c.Summary.NotAccessibleWithoutRoot) + + for _, w := range c.Warnings { + fmt.Fprintf(&sb, "> **Warning**: %s\n\n", mdCell(w, 200)) + } + + fmt.Fprintf(&sb, "## Sections\n\n") + for _, s := range c.Sections { + fmt.Fprintf(&sb, "- %s **%s** - %s\n", checklistStatusEmoji(s.Status), s.Section, + mdCell(checklistSectionHeadline(s), 160)) + } + sb.WriteString("\n") + + if len(c.CoverageManifest) > 0 { + // Declarative boundary of the tool's sight (PR 1A): every known area + // with its coverage state - a not_collected area is a visible line, + // never a silent absence. No action or verdict derives from it. + fmt.Fprintf(&sb, "## Coverage\n\n") + sb.WriteString("| Area | State | Note |\n") + sb.WriteString("|------|-------|------|\n") + for _, a := range c.CoverageManifest { + fmt.Fprintf(&sb, "| %s | %s | %s |\n", + mdCell(a.Area, 30), mdCell(string(a.State), 16), mdCell(a.Note, 140)) + } + sb.WriteString("\n") + } + + if len(c.ManualActions) > 0 { + // The Key column is the STABLE acceptance handle: acceptances.json + // entries reference it (the positional MA-nnn id shifts when + // findings change). + fmt.Fprintf(&sb, "## Manual actions (%d)\n\n", len(c.ManualActions)) + sb.WriteString("| ID | Key | Blocking | Section | Type | Action |\n") + sb.WriteString("|----|-----|----------|---------|------|--------|\n") + for _, a := range c.ManualActions { + blocking := "no" + if a.BlockingCutover { + blocking = "**yes**" + } + if a.Accepted { + blocking = fmt.Sprintf("accepted (%s)", a.AcceptedBy) + } + fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s | %s |\n", + mdCell(a.ID, 10), mdCell(a.Key, 16), mdCell(blocking, 40), mdCell(a.Section, 20), mdCell(a.Type, 30), + mdCell(a.Title+" - "+a.OperatorAction, 140)) + } + sb.WriteString("\n") + } + + fmt.Fprintf(&sb, "## Before shutting down the old server\n\n") + n := 0 + for _, a := range c.ManualActions { + if !a.BlockingCutover || a.Accepted { + continue // a formally accepted action no longer gates the cutover + } + n++ + fmt.Fprintf(&sb, "%d. [%s] %s - %s\n", n, a.ID, mdCell(a.Title, 80), mdCell(a.OperatorAction, 160)) + } + if n == 0 { + if c.Summary.Blocked > 0 { + sb.WriteString("Resolve the blockers listed in the sections above.\n") + } else { + sb.WriteString("No blocking items. Review the manual notes above before proceeding.\n") + } + } + sb.WriteString("\n") + + var checks []string + for _, s := range c.Sections { + checks = append(checks, s.PostCutoverChecks...) + } + if len(checks) > 0 { + fmt.Fprintf(&sb, "## Post-cutover checks\n\n") + for i, chk := range checks { + fmt.Fprintf(&sb, "%d. %s\n", i+1, mdCell(chk, 160)) + } + sb.WriteString("\n") + } + + fmt.Fprintf(&sb, "## Inputs\n\n") + sb.WriteString("| Input | Present | File | SHA256 |\n") + sb.WriteString("|-------|---------|------|--------|\n") + writeInputRow := func(name string, ref ChecklistInputRef) { + present := "no" + if ref.Present { + present = "yes" + } + fmt.Fprintf(&sb, "| %s | %s | %s | %s |\n", + name, present, mdCell(ref.File, 60), mdCell(ref.SHA256, 70)) + } + writeInputRow("source inventory", c.Inputs.SourceInventory) + writeInputRow("destination inventory", c.Inputs.DestinationInventory) + writeInputRow("diff", c.Inputs.Diff) + writeInputRow("policy", c.Inputs.Policy) + writeInputRow("dns plan", c.Inputs.DNSPlan) + writeInputRow("migration report", c.Inputs.MigrationReport) + writeInputRow("acceptances", c.Inputs.Acceptances) + + return os.WriteFile(path, []byte(sb.String()), 0o600) +} diff --git a/internal/accountinventory/checklist_write_test.go b/internal/accountinventory/checklist_write_test.go new file mode 100644 index 00000000..30b15d00 --- /dev/null +++ b/internal/accountinventory/checklist_write_test.go @@ -0,0 +1,165 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// buildTestChecklist assembles a rich, fully deterministic checklist: +// a blocking cron loss, a reissued-but-valid certificate, an expected +// docroot difference, a PHP version change, full apply evidence, and the +// synthetic sections of a mail-bearing account. +func buildTestChecklist(t *testing.T) MigrationChecklist { + t.Helper() + src := chkInventory("source", "192.0.2.1", "srcacct") + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.Domains = []DomainEntry{{Name: "main.example", Type: "main", DocumentRoot: "/home/other/public_html"}} + dest.Cron.Jobs = []CronJobEntry{} + dest.PHP.Items = []PHPEntry{{Domain: "main.example", Version: "ea-php82"}} + dest.SSL.Items = []SSLEntry{{ + Domains: "main.example", Issuer: "E1 (reissued)", ValidFrom: 1_790_000_000, + ValidUntil: chkCertValidUntil, ValidationType: "dv", + }} + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + c.GeneratedAt = "2026-07-02T10:00:00Z" + c.Inputs = ChecklistInputs{ + SourceInventory: ChecklistInputRef{File: "inventory_source.json", SHA256: "aaa", Present: true}, + DestinationInventory: ChecklistInputRef{File: "inventory_destination.json", SHA256: "bbb", Present: true}, + Diff: ChecklistInputRef{File: "inventory_diff.json", SHA256: "ccc", Present: true}, + Policy: ChecklistInputRef{File: "policy_report.json", SHA256: "ddd", Present: true}, + DNSPlan: ChecklistInputRef{Present: false}, + MigrationReport: ChecklistInputRef{File: "report.json", SHA256: "eee", Present: true}, + } + return c +} + +func TestWriteChecklistJSONRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "checklist.json") + c := buildTestChecklist(t) + if err := WriteChecklistJSON(path, c); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var got MigrationChecklist + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if got.Mode != "migration-checklist" || got.FormatVersion != 1 { + t.Errorf("mode/version = %q/%d", got.Mode, got.FormatVersion) + } + if got.OverallStatus != c.OverallStatus || got.Summary != c.Summary { + t.Errorf("overall/summary lost in round-trip") + } + if len(got.Sections) != len(checklistSectionOrder) { + t.Errorf("sections = %d, want %d", len(got.Sections), len(checklistSectionOrder)) + } + if strings.Contains(string(b), ": null") { + t.Error("checklist JSON contains null arrays") + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("json file mode = %o, want 600", perm) + } +} + +func TestWriteChecklistMarkdown(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "checklist.md") + c := buildTestChecklist(t) + if err := WriteChecklistMarkdown(path, c); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + md := string(b) + for _, want := range []string{ + "# Migration Checklist - srcacct", + "**Overall: " + c.OverallStatus + "**", + "## Before shutting down the old server", + "## Post-cutover checks", + "ADAPT_CRON_PATH", + "email_routing", + } { + if !strings.Contains(md, want) { + t.Errorf("markdown missing %q", want) + } + } + if info, err := os.Stat(path); err != nil { + t.Fatal(err) + } else if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("md file mode = %o, want 600", perm) + } +} + +// TestChecklistMarkdownGolden pins the full operator report. Refresh with: +// +// UPDATE_GOLDEN=1 go test ./internal/accountinventory/ -run TestChecklistMarkdownGolden +func TestChecklistMarkdownGolden(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "checklist.md") + if err := WriteChecklistMarkdown(path, buildTestChecklist(t)); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + goldenPath := filepath.Join("..", "testdata", "migration_checklist.md.golden") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile(goldenPath, got, 0o644); err != nil { + t.Fatalf("update golden %s: %v", goldenPath, err) + } + } + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden (run once with UPDATE_GOLDEN=1 to create): %v", err) + } + if string(got) != string(want) { + t.Errorf("checklist markdown differs from golden.\nGOT:\n%s\nWANT:\n%s", got, want) + } +} + +// The markdown is an operator report: long TXT/DKIM material must never be +// re-exposed in full (mdCell previews only). The JSON may carry what the +// diff already carried - no NEW exposure - but the report must not. +func TestChecklistMarkdownDoesNotExposeLongTXT(t *testing.T) { + longDKIM := "v=DKIM1; k=rsa; p=" + strings.Repeat("A", 400) + src := chkInventory("source", "192.0.2.1", "srcacct") + src.DNS.Zones = []DNSZoneResult{planZone("main.example", + aRec("main.example.", "192.0.2.1", 300), + txtRec("default._domainkey", longDKIM, 300), + )} + dest := chkInventory("destination", "192.0.2.2", "srcacct") + + c := BuildChecklist(chkInput(src, dest, nil, chkApplyReport())) + c.GeneratedAt = "t" + + dir := t.TempDir() + path := filepath.Join(dir, "checklist.md") + if err := WriteChecklistMarkdown(path, c); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), longDKIM) { + t.Error("markdown re-exposes the full DKIM TXT value; cells must stay previews") + } +} diff --git a/internal/accountinventory/collector.go b/internal/accountinventory/collector.go new file mode 100644 index 00000000..9317d639 --- /dev/null +++ b/internal/accountinventory/collector.go @@ -0,0 +1,616 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" +) + +type HostInfo struct { + User string + Host string +} + +type CollectResult struct { + Source NormalizedInventory + Dest *NormalizedInventory +} + +func Collect(ctx context.Context, src, dest cpanel.Runner, srcInfo, destInfo HostInfo) (CollectResult, error) { + srcInv, err := collectSide(ctx, src, srcInfo, "source") + if err != nil { + return CollectResult{}, fmt.Errorf("source inventory: %w", err) + } + + var result CollectResult + result.Source = srcInv + + if dest != nil { + destInv, err := collectSide(ctx, dest, destInfo, "destination") + if err != nil { + srcInv.Warnings = append(srcInv.Warnings, fmt.Sprintf("destination inventory failed: %v", err)) + result.Source = srcInv + return result, nil + } + result.Dest = &destInv + } + + return result, nil +} + +func collectSide(ctx context.Context, r cpanel.Runner, info HostInfo, side string) (NormalizedInventory, error) { + inv := NormalizedInventory{ + Account: AccountInfo{ + User: info.User, + Host: info.Host, + CollectedAt: time.Now().UTC().Format(time.RFC3339), + Side: side, + }, + Domains: []DomainEntry{}, + Mailboxes: []MailboxEntry{}, + Databases: []DatabaseEntry{}, + Forwarders: []NormForwarderEntry{}, + Autoresponders: []NormAutoresponderEntry{}, + Warnings: []string{}, + } + + domains, err := cpanel.ListDomains(ctx, r) + if err != nil { + return inv, fmt.Errorf("list domains: %w", err) + } + for _, d := range domains { + inv.Domains = append(inv.Domains, DomainEntry{ + Name: d.Name, + Type: d.Type.String(), + }) + } + + docroots, err := cpanel.ListDocroots(ctx, r) + if err != nil { + inv.Warnings = append(inv.Warnings, fmt.Sprintf("docroots unavailable: %v", err)) + } else { + docrootMap := make(map[string]string, len(docroots)) + for _, dr := range docroots { + docrootMap[dr.Domain] = dr.DocumentRoot + } + for i := range inv.Domains { + if root, ok := docrootMap[inv.Domains[i].Name]; ok { + inv.Domains[i].DocumentRoot = root + } + } + } + + mailboxesUnavailable := false + accounts, err := cpanel.ListEmailAccounts(ctx, r) + if err != nil { + mailboxesUnavailable = true + inv.Warnings = append(inv.Warnings, fmt.Sprintf("Email accounts unavailable: %v", err)) + } else { + for _, a := range accounts { + local := a.Email + domain := a.Domain + user := local + if at := strings.IndexByte(local, '@'); at >= 0 { + user = local[:at] + } + inv.Mailboxes = append(inv.Mailboxes, MailboxEntry{ + Email: a.Email, + Domain: domain, + User: user, + DiskUsage: int64(a.DiskUsedBytes), + }) + } + } + + dbs, err := cpanel.ListDatabases(ctx, r) + if err != nil { + inv.Warnings = append(inv.Warnings, fmt.Sprintf("databases unavailable: %v", err)) + } else { + for _, db := range dbs { + inv.Databases = append(inv.Databases, DatabaseEntry{ + Name: db.Database, + DiskUsage: int64(db.DiskUsage), + Users: db.Users, + }) + } + } + + for _, d := range domains { + // Forwarders and autoresponders are INDEPENDENT collections: a + // failure of one listing must not silently lose the other + // (go-review 2B-2 finding 4). + fwds, err := cpanel.ListForwarders(ctx, r, d.Name) + if err != nil { + inv.Warnings = append(inv.Warnings, fmt.Sprintf("forwarders for %s unavailable: %v", d.Name, err)) + } else { + for _, f := range fwds { + inv.Forwarders = append(inv.Forwarders, NormForwarderEntry{ + Source: f.Dest, + Destination: f.Forward, + Domain: d.Name, + }) + } + } + + ars, err := cpanel.ListAutoresponders(ctx, r, d.Name) + if err != nil { + inv.Warnings = append(inv.Warnings, fmt.Sprintf("autoresponders for %s unavailable: %v", d.Name, err)) + continue + } + for _, a := range ars { + // Real servers return the FULL address in `email` and no domain + // field (2B-2-pre fact 2); tolerate a local-part shape by + // completing it with the QUERIED domain. + addr := a.Email + if !strings.Contains(addr, "@") { + addr = addr + "@" + d.Name + } + entry := NormAutoresponderEntry{ + Email: addr, + Domain: d.Name, + Subject: a.Subject, + Interval: int(a.Interval), + } + // The body and every other content field exist ONLY in + // get_auto_responder (2B-2-pre fact 3). A per-address failure + // keeps the list-level entry with BodyCollected=false - honest + // degradation, never a dropped section. + det, err := cpanel.GetAutoresponder(ctx, r, addr) + if err != nil { + inv.Warnings = append(inv.Warnings, fmt.Sprintf("autoresponder body for %s unavailable: %v", addr, err)) + } else { + entry.From = det.From + entry.Body = det.Body + entry.IsHTML = int(det.IsHTML) + entry.Interval = int(det.Interval) + entry.Start = int64(det.Start) + entry.Stop = int64(det.Stop) + entry.Charset = det.Charset + // The get subject is authoritative VERBATIM (even empty): + // equivalence must compare the live value, not the stale + // list one (go-review 2B-2 finding 3). + entry.Subject = det.Subject + entry.BodyCollected = true + } + inv.Autoresponders = append(inv.Autoresponders, entry) + } + } + + inv.FTP = collectFTP(ctx, r) + inv.SSL = collectSSL(ctx, r) + inv.PHP = collectPHP(ctx, r) + inv.DNS = collectDNS(ctx, r, inv.Domains) + inv.Cron = collectCron(ctx, r) + inv.EmailRouting = collectEmailRouting(ctx, r) + inv.DefaultAddresses = collectDefaultAddresses(ctx, r) + inv.EmailFilters = collectEmailFilters(ctx, r, inv.Mailboxes, mailboxesUnavailable) + inv.Redirects = collectRedirects(ctx, r) + + return inv, nil +} + +func collectCron(ctx context.Context, r cpanel.Runner) CronSection { + sec := NewEmptyCronSection() + + res, err := cpanel.FetchCrontab(ctx, r) + if err != nil { + sec.Available = false + sec.Method = "unavailable" + // Hard failures land in Errors; Warnings stays for soft conditions + // (empty crontab, unparsable lines) so JSON consumers can key off + // a non-empty errors array. + sec.Errors = append(sec.Errors, fmt.Sprintf("crontab unavailable: %v", err)) + return sec + } + + sec.Available = true + sec.Method = "ssh_crontab_l" + sec.CommentsCount = res.CommentsCount + sec.DisabledJobsCount = res.DisabledJobsCount + sec.Warnings = append(sec.Warnings, res.Warnings...) + for _, j := range res.Jobs { + warnings := j.Warnings + if warnings == nil { + warnings = []string{} + } + sec.Jobs = append(sec.Jobs, CronJobEntry{ + Type: j.Type, + Minute: j.Minute, + Hour: j.Hour, + DayOfMonth: j.DayOfMonth, + Month: j.Month, + DayOfWeek: j.DayOfWeek, + Macro: j.Macro, + CommandRedacted: j.CommandRedacted, + // CommandClear and RawLine are intentionally NOT populated here: the + // read-only inventory artifact (inventory_source.json) must never carry + // the un-redacted command, which would leak secrets like secure=. + // The Batch-D apply writer re-collects the source crontab fresh at apply + // time and does not read the raw from this stale artifact. + CommandSHA256: j.CommandSHA256, + RawLineSHA256: j.RawLineSHA256, + Enabled: j.Enabled, + LineNumber: j.LineNumber, + Warnings: warnings, + // CommandCollected marks a real job parsed from the live crontab so the + // plan classifies it (create/skip/manual). Comparison rides CommandSHA256, + // not the raw text, so the marker no longer implies the raw is stored. + CommandCollected: j.CommandRedacted != "", + }) + } + for _, e := range res.Environment { + sec.Environment = append(sec.Environment, CronEnvEntry{ + Name: e.Name, + ValueRedacted: e.ValueRedacted, + // ValueClear intentionally NOT populated (secret-leak surface, see jobs). + LineNumber: e.LineNumber, + ValueCollected: e.ValueRedacted != "", + }) + } + return sec +} + +func collectFTP(ctx context.Context, r cpanel.Runner) FTPSection { + sec := FTPSection{ + ConfigSection: ConfigSection{Method: "uapi", SourceFunction: "Ftp::list_ftp_with_disk", Warnings: []string{}}, + Items: []FTPEntry{}, + } + accounts, err := cpanel.ListFTPAccounts(ctx, r) + if err != nil { + sec.Available = false + sec.Warnings = append(sec.Warnings, fmt.Sprintf("FTP accounts unavailable: %v", err)) + return sec + } + sec.Available = true + for _, a := range accounts { + sec.Items = append(sec.Items, FTPEntry{ + Login: a.Login, + Type: a.AcctType, + Dir: a.Dir, + DiskUsed: int64(a.DiskUsed), + }) + } + return sec +} + +func collectSSL(ctx context.Context, r cpanel.Runner) SSLSection { + sec := SSLSection{ + ConfigSection: ConfigSection{Method: "uapi", SourceFunction: "SSL::list_certs", Warnings: []string{}}, + Items: []SSLEntry{}, + } + certs, err := cpanel.ListSSLCerts(ctx, r) + if err != nil { + sec.Available = false + sec.Warnings = append(sec.Warnings, fmt.Sprintf("SSL certificates unavailable: %v", err)) + return sec + } + sec.Available = true + for _, c := range certs { + sec.Items = append(sec.Items, SSLEntry{ + Domains: string(c.Domains), + Issuer: c.IssuerCN, + ValidFrom: int64(c.NotBefore), + ValidUntil: int64(c.NotAfter), + IsSelfSigned: c.IsSelfSigned != 0, + ValidationType: c.ValidationType, + }) + } + return sec +} + +func collectDNS(ctx context.Context, r cpanel.Runner, domains []DomainEntry) DNSSection { + sec := DNSSection{ + ConfigSection: ConfigSection{Warnings: []string{}}, + Zones: []DNSZoneResult{}, + } + + seen := map[string]bool{} + for _, d := range domains { + if d.Type == "sub" { + continue + } + zone := d.Name + if seen[zone] { + continue + } + seen[zone] = true + + sec.Zones = append(sec.Zones, FetchDNSZone(ctx, r, zone)) + } + + anyAvailable := false + for _, z := range sec.Zones { + if z.Available { + anyAvailable = true + break + } + } + sec.Available = anyAvailable + if anyAvailable { + for _, z := range sec.Zones { + if z.Available { + sec.Method = z.Method + sec.SourceFunction = z.SourceFunction + break + } + } + } else { + sec.Method = "unavailable" + } + + return sec +} + +// FetchDNSZone fetches one zone with the collector's exact semantics: +// UAPI DNS::parse_zone first, API2 ZoneEdit::fetchzone_records fallback, +// unavailable-with-warning when both fail (never fatal). Exported for +// `dns verify` (PR 6C), which re-fetches destination zones and must see +// the same shapes the inventory saw. +func FetchDNSZone(ctx context.Context, r cpanel.Runner, zone string) DNSZoneResult { + zr := DNSZoneResult{ + Zone: zone, + Records: []cpanel.DNSRecord{}, + Warnings: []string{}, + Errors: []string{}, + } + + records, err := cpanel.FetchDNSZoneUAPI(ctx, r, zone) + if err == nil { + zr.Available = true + zr.Method = "uapi" + zr.SourceFunction = "DNS::parse_zone" + zr.Records = records + zr.RawIncluded = hasRawRecords(records) + return zr + } + + records, err = cpanel.FetchDNSZoneAPI2(ctx, r, zone) + if err == nil { + zr.Available = true + zr.Method = "api2" + zr.SourceFunction = "ZoneEdit::fetchzone_records" + zr.Records = records + zr.RawIncluded = hasRawRecords(records) + } else { + zr.Available = false + zr.Method = "unavailable" + zr.Warnings = append(zr.Warnings, fmt.Sprintf("DNS zone %s unavailable: %v", zone, err)) + } + return zr +} + +func hasRawRecords(records []cpanel.DNSRecord) bool { + for _, r := range records { + if r.Raw != nil { + return true + } + } + return false +} + +func collectPHP(ctx context.Context, r cpanel.Runner) PHPSection { + sec := PHPSection{ + ConfigSection: ConfigSection{Method: "uapi", SourceFunction: "LangPHP::php_get_vhost_versions", Warnings: []string{}}, + Items: []PHPEntry{}, + } + versions, err := cpanel.ListPHPVersions(ctx, r) + if err != nil { + sec.Available = false + sec.Warnings = append(sec.Warnings, fmt.Sprintf("PHP versions unavailable: %v", err)) + return sec + } + sec.Available = true + for _, v := range versions { + sec.Items = append(sec.Items, PHPEntry{ + Domain: v.Vhost, + Version: v.Version, + }) + } + return sec +} + +// --- PR 7E sections ------------------------------------------------------- + +func collectEmailRouting(ctx context.Context, r cpanel.Runner) EmailRoutingSection { + sec := EmailRoutingSection{ + ConfigSection: ConfigSection{Method: "uapi", SourceFunction: "Email::list_mxs", Warnings: []string{}}, + Items: []EmailRoutingEntry{}, + } + domains, err := cpanel.ListMXs(ctx, r) + if err != nil { + sec.Available = false + sec.Method = "unavailable" + sec.Warnings = append(sec.Warnings, fmt.Sprintf("email routing unavailable: %v", err)) + return sec + } + sec.Available = true + for _, d := range domains { + mx := []MXRecordEntry{} + for _, e := range d.Entries { + mx = append(mx, MXRecordEntry{Priority: int64(e.Priority), Exchange: e.MX}) + } + sec.Items = append(sec.Items, EmailRoutingEntry{ + Domain: d.Domain, + Routing: d.MXCheck, + Detected: d.Detected, + AlwaysAccept: d.AlwaysAccept != 0, + MXRecords: mx, + }) + } + return sec +} + +func collectDefaultAddresses(ctx context.Context, r cpanel.Runner) DefaultAddressSection { + sec := DefaultAddressSection{ + ConfigSection: ConfigSection{Method: "uapi", SourceFunction: "Email::list_default_address", Warnings: []string{}}, + Items: []DefaultAddressEntry{}, + } + entries, err := cpanel.ListDefaultAddresses(ctx, r) + if err != nil { + sec.Available = false + sec.Method = "unavailable" + sec.Warnings = append(sec.Warnings, fmt.Sprintf("default addresses unavailable: %v", err)) + return sec + } + sec.Available = true + for _, e := range entries { + sec.Items = append(sec.Items, DefaultAddressEntry{ + Domain: e.Domain, + DefaultAddress: e.DefaultAddress, + }) + } + return sec +} + +// collectEmailFilters gathers the account-level filter set plus one +// per-mailbox set per real mailbox (the Main Account pseudo-entry has +// no "@" and is skipped). A failing per-mailbox call degrades to a +// warning; only the account-level failure marks the whole section +// unavailable. mailboxesUnavailable distinguishes "the mailbox list +// itself failed" from "the account has no mailboxes": in the former +// case the section stays available but records that only the +// account-level scope was collected - never a silent coverage loss. +func collectEmailFilters(ctx context.Context, r cpanel.Runner, mailboxes []MailboxEntry, mailboxesUnavailable bool) EmailFilterSection { + sec := EmailFilterSection{ + ConfigSection: ConfigSection{Method: "uapi", SourceFunction: "Email::list_filters", Warnings: []string{}}, + Items: []NormEmailFilterEntry{}, + } + appendFilters := func(account string, filters []cpanel.EmailFilterEntry) { + for _, f := range filters { + entry := NormEmailFilterEntry{ + Account: account, + FilterName: f.FilterName, + Enabled: f.Enabled != 0, + RuleCount: len(f.Rules), + ActionCount: len(f.Actions), + } + gf, err := cpanel.GetEmailFilter(ctx, r, f.FilterName, account) + if err != nil { + sec.Warnings = append(sec.Warnings, + fmt.Sprintf("get_filter %q (account=%q) failed: %v - rules not collected", f.FilterName, account, err)) + } else { + rules, actions := decodeFilterRulesActions(gf) + if len(rules) != len(gf.Rules) || len(actions) != len(gf.Actions) { + sec.Warnings = append(sec.Warnings, + fmt.Sprintf("get_filter %q (account=%q): decoded %d/%d rules/actions but raw had %d/%d - shape surprise, rules not trusted", + f.FilterName, account, len(rules), len(actions), len(gf.Rules), len(gf.Actions))) + } else { + entry.Rules = rules + entry.Actions = actions + entry.RulesCollected = true + } + } + sec.Items = append(sec.Items, entry) + } + } + accountLevel, err := cpanel.ListEmailFilters(ctx, r, "") + if err != nil { + sec.Available = false + sec.Method = "unavailable" + sec.Warnings = append(sec.Warnings, fmt.Sprintf("email filters unavailable: %v", err)) + return sec + } + sec.Available = true + appendFilters("", accountLevel) + if mailboxesUnavailable { + sec.Warnings = append(sec.Warnings, + "mailbox list unavailable: only the account-level filter scope was collected") + } + for _, mb := range mailboxes { + if !strings.Contains(mb.Email, "@") { + continue // Main Account pseudo-mailbox + } + filters, err := cpanel.ListEmailFilters(ctx, r, mb.Email) + if err != nil { + sec.Warnings = append(sec.Warnings, fmt.Sprintf("filters for %s unavailable: %v", mb.Email, err)) + continue + } + appendFilters(mb.Email, filters) + } + // Full tie-break: the UAPI backend's array order is not proven + // stable across invocations, so identical Account+FilterName pairs + // must still order deterministically. + sort.SliceStable(sec.Items, func(i, j int) bool { + a, b := sec.Items[i], sec.Items[j] + if a.Account != b.Account { + return a.Account < b.Account + } + if a.FilterName != b.FilterName { + return a.FilterName < b.FilterName + } + if a.Enabled != b.Enabled { + return !a.Enabled && b.Enabled + } + if a.RuleCount != b.RuleCount { + return a.RuleCount < b.RuleCount + } + return a.ActionCount < b.ActionCount + }) + return sec +} + +// decodeFilterRulesActions converts the raw JSON rule/action arrays from +// get_filter into the typed inventory fields. Unknown shapes degrade to +// empty Part/Match/Val or Action/Dest, never a fatal error - the plan +// will classify the filter manual if equality is unprovable. +func decodeFilterRulesActions(gf cpanel.GetEmailFilterResult) ([]FilterRule, []FilterAction) { + rules := make([]FilterRule, 0, len(gf.Rules)) + for _, raw := range gf.Rules { + var dec cpanel.FilterRuleDecoded + if err := json.Unmarshal(raw, &dec); err == nil { + rules = append(rules, FilterRule{ + Part: dec.Part, + Match: dec.Match, + Opt: dec.Opt, + Val: dec.Val, + }) + } + } + actions := make([]FilterAction, 0, len(gf.Actions)) + for _, raw := range gf.Actions { + var dec cpanel.FilterActionDecoded + if err := json.Unmarshal(raw, &dec); err == nil { + actions = append(actions, FilterAction{ + Action: dec.Action, + Dest: dec.Dest, + }) + } + } + return rules, actions +} + +func collectRedirects(ctx context.Context, r cpanel.Runner) RedirectSection { + sec := RedirectSection{ + ConfigSection: ConfigSection{Method: "uapi", SourceFunction: "Mime::list_redirects", Warnings: []string{}}, + Items: []RedirectEntry{}, + } + entries, err := cpanel.ListRedirects(ctx, r) + if err != nil { + sec.Available = false + sec.Method = "unavailable" + sec.Warnings = append(sec.Warnings, fmt.Sprintf("redirects unavailable: %v", err)) + return sec + } + sec.Available = true + for _, e := range entries { + sec.Items = append(sec.Items, RedirectEntry{ + Domain: e.Domain, + Source: e.Source, + Destination: e.Destination, + Kind: e.Kind, + Type: e.Type, + StatusCode: int64(e.StatusCode), + Wildcard: e.Wildcard != 0, + MatchWWW: e.MatchWWW != 0, + }) + } + return sec +} diff --git a/internal/accountinventory/collector_7e_test.go b/internal/accountinventory/collector_7e_test.go new file mode 100644 index 00000000..920af5ae --- /dev/null +++ b/internal/accountinventory/collector_7e_test.go @@ -0,0 +1,261 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "context" + "testing" +) + +// The 7E sections (email routing, default address, email filters, +// redirects) follow the ConfigSection contract: available:true with +// items on success, available:false + method:"unavailable" + warning on +// failure, never fatal. Fixtures are the byte-verified real captures +// (PR7E_PRE_CAPTURES.md); filters use the docs-derived synthetic one. + +func TestCollectEmailRouting(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{ + "Email list_mxs": loadFixture(t, "email_list_mxs_realserver.json"), + }} + sec := collectEmailRouting(context.Background(), runner) + if !sec.Available || sec.Method != "uapi" || sec.SourceFunction != "Email::list_mxs" { + t.Fatalf("section meta = %+v", sec.ConfigSection) + } + if len(sec.Items) != 2 { + t.Fatalf("got %d items, want 2", len(sec.Items)) + } + local := sec.Items[0] + if local.Domain != "example.com" || local.Routing != "local" || !local.AlwaysAccept { + t.Errorf("[0] = %+v, want example.com/local/always_accept", local) + } + if len(local.MXRecords) != 1 || local.MXRecords[0].Priority != 0 || local.MXRecords[0].Exchange != "example.com" { + t.Errorf("[0] mx_records = %+v", local.MXRecords) + } + remote := sec.Items[1] + if remote.Domain != "example.com" || remote.Routing != "remote" || remote.AlwaysAccept { + t.Errorf("[1] = %+v, want example.com/remote/no-always-accept", remote) + } +} + +func TestCollectEmailRoutingUnavailable(t *testing.T) { + sec := collectEmailRouting(context.Background(), &fakeRunner{responses: map[string][]byte{}}) + if sec.Available || sec.Method != "unavailable" || len(sec.Warnings) == 0 { + t.Fatalf("section = %+v, want unavailable with warning", sec.ConfigSection) + } + if sec.Items == nil { + t.Fatal("Items must stay non-nil") + } +} + +func TestCollectDefaultAddresses(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{ + "Email list_default_address": loadFixture(t, "email_default_address_realserver.json"), + }} + sec := collectDefaultAddresses(context.Background(), runner) + if !sec.Available || sec.SourceFunction != "Email::list_default_address" { + t.Fatalf("section meta = %+v", sec.ConfigSection) + } + if len(sec.Items) != 7 { + t.Fatalf("got %d items, want 7 (subdomains included)", len(sec.Items)) + } + // Sorted by domain (cz.example.com first); the opaque value keeps + // its embedded quotes. + if sec.Items[0].Domain != "cz.example.com" { + t.Errorf("[0] domain = %q, want cz.example.com (sorted)", sec.Items[0].Domain) + } + byDomain := map[string]string{} + for _, e := range sec.Items { + byDomain[e.Domain] = e.DefaultAddress + } + if got := byDomain["example.com"]; got != `":fail: No Such User Here"` { + t.Errorf("example.com default = %q, want embedded-quotes value", got) + } +} + +func TestCollectEmailFilters(t *testing.T) { + // One fixture serves both the account-level and the per-mailbox + // call (the collector labels entries via Account). The pseudo + // mailbox (no "@") must be skipped. The get_filter fixture serves + // all names (the fakeRunner cannot distinguish per-filtername calls). + runner := &fakeRunner{responses: map[string][]byte{ + "Email list_filters": loadFixture(t, "email_list_filters.json"), + "Email get_filter": loadFixture(t, "email_get_filter_spam-to-junk.json"), + }} + mailboxes := []MailboxEntry{ + {Email: "info@example.com"}, + {Email: "example"}, // Main Account pseudo-entry + } + sec := collectEmailFilters(context.Background(), runner, mailboxes, false) + if !sec.Available || sec.SourceFunction != "Email::list_filters" { + t.Fatalf("section meta = %+v", sec.ConfigSection) + } + if len(sec.Items) != 4 { + t.Fatalf("got %d items, want 4 (2 account-level + 2 for info@)", len(sec.Items)) + } + // Sorted by account ("" first) then filter name; counts only. + first := sec.Items[0] + if first.Account != "" || first.FilterName != "legacy-disabled" || first.Enabled { + t.Errorf("[0] = %+v, want account-level legacy-disabled disabled", first) + } + if first.RuleCount != 2 || first.ActionCount != 2 { + t.Errorf("[0] counts = %d/%d, want 2/2", first.RuleCount, first.ActionCount) + } + if sec.Items[2].Account != "info@example.com" { + t.Errorf("[2] account = %q, want info@example.com", sec.Items[2].Account) + } +} + +func TestCollectEmailFiltersUnavailable(t *testing.T) { + sec := collectEmailFilters(context.Background(), &fakeRunner{responses: map[string][]byte{}}, nil, false) + if sec.Available || sec.Method != "unavailable" || len(sec.Warnings) == 0 { + t.Fatalf("section = %+v, want unavailable with warning", sec.ConfigSection) + } +} + +// The interleaving the review flagged: the mailbox list itself failed +// but the account-level filter call succeeds. The section must stay +// available AND record the narrowed scope - never a silent coverage +// loss. +func TestCollectEmailFiltersMailboxListUnavailable(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{ + "Email list_filters": loadFixture(t, "email_list_filters.json"), + "Email get_filter": loadFixture(t, "email_get_filter_spam-to-junk.json"), + }} + sec := collectEmailFilters(context.Background(), runner, nil, true) + if !sec.Available { + t.Fatalf("section = %+v, want available (account-level succeeded)", sec.ConfigSection) + } + if len(sec.Items) != 2 { + t.Errorf("got %d items, want 2 account-level ones", len(sec.Items)) + } + found := false + for _, w := range sec.Warnings { + if contains(w, "account-level") { + found = true + } + } + if !found { + t.Errorf("missing narrowed-scope warning, got: %v", sec.Warnings) + } +} + +// 2B-3: the enriched collector populates Rules, Actions and +// RulesCollected from get_filter. When get_filter fails, the entry +// degrades gracefully (rules_collected=false, warning, never lost). +func TestCollectEmailFiltersRulesCollected(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{ + "Email list_filters": loadFixture(t, "email_list_filters.json"), + "Email get_filter": loadFixture(t, "email_get_filter_spam-to-junk.json"), + }} + sec := collectEmailFilters(context.Background(), runner, nil, false) + if len(sec.Items) != 2 { + t.Fatalf("got %d items, want 2", len(sec.Items)) + } + for _, item := range sec.Items { + if !item.RulesCollected { + t.Errorf("filter %q: rules_collected=false, want true", item.FilterName) + } + if len(item.Rules) == 0 { + t.Errorf("filter %q: no rules populated", item.FilterName) + } + if len(item.Actions) == 0 { + t.Errorf("filter %q: no actions populated", item.FilterName) + } + } + // Verify first entry has the expected rule content from the fixture. + // Note: fakeRunner returns the same get_filter fixture for all calls, + // so both entries get the spam-to-junk content. + first := sec.Items[0] + if first.Rules[0].Part != "$header_subject:" || first.Rules[0].Match != "contains" || first.Rules[0].Val != "[SPAM]" { + t.Errorf("first filter rule = %+v, want $header_subject: contains [SPAM]", first.Rules[0]) + } + if first.Actions[0].Action != "save" { + t.Errorf("first filter action = %+v, want save", first.Actions[0]) + } +} + +// 2B-3: when get_filter fails, the entry must carry +// rules_collected=false and a warning, but the entry itself survives. +func TestCollectEmailFiltersGetFilterFails(t *testing.T) { + // No get_filter response -> every get_filter call fails + runner := &fakeRunner{responses: map[string][]byte{ + "Email list_filters": loadFixture(t, "email_list_filters.json"), + }} + sec := collectEmailFilters(context.Background(), runner, nil, false) + if !sec.Available { + t.Fatalf("section should still be available") + } + if len(sec.Items) != 2 { + t.Fatalf("got %d items, want 2 (entries must survive get_filter failure)", len(sec.Items)) + } + for _, item := range sec.Items { + if item.RulesCollected { + t.Errorf("filter %q: rules_collected=true, want false (get_filter failed)", item.FilterName) + } + if len(item.Rules) != 0 { + t.Errorf("filter %q: has %d rules, want 0 (get_filter failed)", item.FilterName, len(item.Rules)) + } + } + if len(sec.Warnings) != 2 { + t.Errorf("expected 2 get_filter warnings, got %d: %v", len(sec.Warnings), sec.Warnings) + } +} + +func TestCollectRedirects(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{ + "Mime list_redirects": loadFixture(t, "mime_redirects_realserver.json"), + }} + sec := collectRedirects(context.Background(), runner) + if !sec.Available || sec.SourceFunction != "Mime::list_redirects" { + t.Fatalf("section meta = %+v", sec.ConfigSection) + } + if len(sec.Items) != 3 { + t.Fatalf("got %d items, want 3", len(sec.Items)) + } + // Sorted by domain then source: the two CMS rewrites (noleggio.*) + // precede the genuine 301 (sub-uk.*). + last := sec.Items[2] + if last.Domain != "sub-uk.example.com" || last.StatusCode != 301 || last.Type != "permanent" { + t.Errorf("[2] = %+v, want the genuine 301", last) + } + if !last.Wildcard || !last.MatchWWW { + t.Errorf("[2] wildcard/matchwww = %v/%v, want true/true", last.Wildcard, last.MatchWWW) + } + if cms := sec.Items[0]; cms.StatusCode != 0 || cms.Kind != "rewrite" || cms.Type != "temporary" { + t.Errorf("[0] = %+v, want CMS rewrite with no status code", cms) + } +} + +func TestCollectRedirectsUnavailable(t *testing.T) { + sec := collectRedirects(context.Background(), &fakeRunner{responses: map[string][]byte{}}) + if sec.Available || sec.Method != "unavailable" || len(sec.Warnings) == 0 { + t.Fatalf("section = %+v, want unavailable with warning", sec.ConfigSection) + } +} + +// A legitimately mailbox-less account (list succeeded, zero entries) +// must NOT get the narrowed-scope warning. +func TestCollectEmailFiltersNoWarningWhenNoMailboxes(t *testing.T) { + // get_filter returns a single-filter response; the fakeRunner cannot + // distinguish per-filtername calls (values are in env vars, not the + // script text), so we supply a generic get_filter fixture. The collector + // tolerates shape mismatches between list and get gracefully. + runner := &fakeRunner{responses: map[string][]byte{ + "Email list_filters": loadFixture(t, "email_list_filters.json"), + "Email get_filter": loadFixture(t, "email_get_filter_spam-to-junk.json"), + }} + sec := collectEmailFilters(context.Background(), runner, nil, false) + if !sec.Available { + t.Fatalf("section = %+v, want available", sec.ConfigSection) + } + if len(sec.Warnings) != 0 { + t.Errorf("no warnings expected for a mailbox-less account, got: %v", sec.Warnings) + } + // 2B-3: every entry must have rules_collected=true when get_filter succeeds. + for _, item := range sec.Items { + if !item.RulesCollected { + t.Errorf("filter %q: rules_collected=false, want true", item.FilterName) + } + } +} diff --git a/internal/accountinventory/collector_test.go b/internal/accountinventory/collector_test.go new file mode 100644 index 00000000..208c71ce --- /dev/null +++ b/internal/accountinventory/collector_test.go @@ -0,0 +1,699 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +type fakeRunner struct { + responses map[string][]byte +} + +func (f *fakeRunner) RunScript(_ context.Context, script string, _ map[string]string) ([]byte, error) { + for key, resp := range f.responses { + if len(script) > 0 && contains(script, key) { + return resp, nil + } + } + return nil, fmt.Errorf("fakeRunner: no response for script containing any known key") +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && findSubstring(s, sub) +} + +func findSubstring(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func loadFixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "testdata", name)) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + return b +} + +func newFakeRunnerFromFixtures(t *testing.T) *fakeRunner { + return &fakeRunner{responses: map[string][]byte{ + "DomainInfo list_domains": loadFixture(t, "domaininfo_list.json"), + "DomainInfo domains_data": loadFixture(t, "domaininfo_domains_data.json"), + "Email list_pops_with_disk": loadFixture(t, "email_list_pops.json"), + "Email list_forwarders": loadFixture(t, "email_forwarders.json"), + "Email list_auto_responders": loadFixture(t, "email_autoresponders.json"), + "Mysql list_databases": wrapUAPI(`[{"database":"src_wp","disk_usage":1024,"users":["src_admin"]}]`), + "Mysql list_users": wrapUAPI(`[{"user":"src_admin","short_user":"admin","databases":["src_wp"]}]`), + "Ftp list_ftp_with_disk": loadFixture(t, "ftp_list.json"), + "SSL list_certs": loadFixture(t, "ssl_list_certs.json"), + "LangPHP php_get_vhost_versions": loadFixture(t, "php_vhost_versions.json"), + "ZoneEdit fetchzone_records": loadFixture(t, "dns_fetchzone_records.json"), + "crontab -l": []byte(fakeCrontabOutput), + }} +} + +// fakeCrontabOutput mimics the marker-based crontab fetch script output. +const fakeCrontabOutput = `MAILTO=admin@main.example +# nightly backup +0 3 * * * /usr/local/bin/backup.sh --password=supersecret +@daily /usr/bin/php /home/u/cron.php +#30 2 * * 0 /bin/disabled-weekly.sh +__CRONTAB_RC:0__ +` + +func wrapUAPI(data string) []byte { + return []byte(fmt.Sprintf(`{"result":{"data":%s,"errors":null,"messages":null,"status":1}}`, data)) +} + +func TestCollectSourceOnly(t *testing.T) { + runner := newFakeRunnerFromFixtures(t) + ctx := context.Background() + + result, err := Collect(ctx, runner, nil, HostInfo{User: "srcuser", Host: "192.0.2.1"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + if result.Source.Account.User != "srcuser" { + t.Errorf("Source.Account.User = %q, want %q", result.Source.Account.User, "srcuser") + } + if result.Source.Account.Side != "source" { + t.Errorf("Source.Account.Side = %q, want %q", result.Source.Account.Side, "source") + } + if len(result.Source.Domains) == 0 { + t.Error("Source.Domains is empty") + } + if result.Dest != nil { + t.Error("Dest should be nil when no dest runner provided") + } +} + +func TestCollectWithDestination(t *testing.T) { + src := newFakeRunnerFromFixtures(t) + dest := newFakeRunnerFromFixtures(t) + ctx := context.Background() + + result, err := Collect(ctx, src, dest, HostInfo{User: "src", Host: "198.51.100.1"}, HostInfo{User: "dst", Host: "198.51.100.2"}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + if result.Source.Account.User != "src" { + t.Errorf("Source user = %q", result.Source.Account.User) + } + if result.Dest == nil { + t.Fatal("Dest should not be nil") + } + if result.Dest.Account.User != "dst" { + t.Errorf("Dest user = %q", result.Dest.Account.User) + } + if result.Dest.Account.Side != "destination" { + t.Errorf("Dest side = %q", result.Dest.Account.Side) + } +} + +func TestCollectForwardersAndAutoresponders(t *testing.T) { + runner := newFakeRunnerFromFixtures(t) + ctx := context.Background() + + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + if len(result.Source.Forwarders) == 0 { + t.Error("expected forwarders") + } + found := false + for _, f := range result.Source.Forwarders { + if f.Source == "info@main.example" && f.Destination == "admin@gmail.com" { + found = true + } + } + if !found { + t.Errorf("missing expected forwarder info@main.example -> admin@gmail.com, got: %+v", result.Source.Forwarders) + } + if len(result.Source.Autoresponders) == 0 { + t.Error("expected autoresponders") + } +} + +func TestCollectForwardersWarningNotFatal(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{ + "DomainInfo list_domains": loadFixture(t, "domaininfo_list.json"), + "DomainInfo domains_data": loadFixture(t, "domaininfo_domains_data.json"), + "Email list_pops_with_disk": loadFixture(t, "email_list_pops.json"), + "Mysql list_databases": wrapUAPI(`[]`), + "Mysql list_users": wrapUAPI(`[]`), + }} + ctx := context.Background() + + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect should not fail on forwarder error: %v", err) + } + if len(result.Source.Forwarders) != 0 { + t.Errorf("Forwarders should be empty, got %d", len(result.Source.Forwarders)) + } + hasWarning := false + for _, w := range result.Source.Warnings { + if contains(w, "forwarder") || contains(w, "Forwarder") { + hasWarning = true + break + } + } + if !hasWarning { + t.Errorf("expected warning about forwarders, got: %v", result.Source.Warnings) + } +} + +func TestCollectFTPSSLPHP(t *testing.T) { + runner := newFakeRunnerFromFixtures(t) + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + if !result.Source.FTP.Available { + t.Error("FTP should be available") + } + if len(result.Source.FTP.Items) != 2 { + t.Errorf("FTP items = %d, want 2", len(result.Source.FTP.Items)) + } + if !result.Source.SSL.Available { + t.Error("SSL should be available") + } + if len(result.Source.SSL.Items) != 2 { + t.Errorf("SSL items = %d, want 2", len(result.Source.SSL.Items)) + } + if !result.Source.PHP.Available { + t.Error("PHP should be available") + } + if len(result.Source.PHP.Items) != 2 { + t.Errorf("PHP items = %d, want 2", len(result.Source.PHP.Items)) + } + if result.Source.FTP.SourceFunction != "Ftp::list_ftp_with_disk" { + t.Errorf("FTP source_function = %q", result.Source.FTP.SourceFunction) + } +} + +func TestCollectFTPFailOthersOK(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{ + "DomainInfo list_domains": loadFixture(t, "domaininfo_list.json"), + "DomainInfo domains_data": loadFixture(t, "domaininfo_domains_data.json"), + "Email list_pops_with_disk": loadFixture(t, "email_list_pops.json"), + "Email list_forwarders": loadFixture(t, "email_forwarders.json"), + "Email list_auto_responders": loadFixture(t, "email_autoresponders.json"), + "Mysql list_databases": wrapUAPI(`[]`), + "Mysql list_users": wrapUAPI(`[]`), + "SSL list_certs": loadFixture(t, "ssl_list_certs.json"), + "LangPHP php_get_vhost_versions": loadFixture(t, "php_vhost_versions.json"), + }} + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect should not fail: %v", err) + } + if result.Source.FTP.Available { + t.Error("FTP should be unavailable (no fixture)") + } + if len(result.Source.FTP.Warnings) == 0 { + t.Error("FTP should have a warning") + } + if !result.Source.SSL.Available { + t.Error("SSL should still be available") + } + if !result.Source.PHP.Available { + t.Error("PHP should still be available") + } +} + +func TestCollectDomainsFatal(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{}} + ctx := context.Background() + + _, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err == nil { + t.Fatal("expected fatal error when domains cannot be listed") + } +} + +// --------------------------------------------------------------------------- +// DNS collection tests +// --------------------------------------------------------------------------- + +func TestCollectDNSAPI2Success(t *testing.T) { + runner := newFakeRunnerFromFixtures(t) + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + if !result.Source.DNS.Available { + t.Error("DNS should be available") + } + if len(result.Source.DNS.Zones) == 0 { + t.Fatal("expected at least one DNS zone") + } + found := false + for _, z := range result.Source.DNS.Zones { + if z.Available && z.Method == "api2" { + found = true + if len(z.Records) == 0 { + t.Errorf("zone %s has no records", z.Zone) + } + if z.SourceFunction != "ZoneEdit::fetchzone_records" { + t.Errorf("zone %s source_function = %q", z.Zone, z.SourceFunction) + } + } + } + if !found { + t.Error("expected at least one zone with method=api2") + } +} + +func TestCollectDNSUAPISuccessBypass(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{ + "DomainInfo list_domains": loadFixture(t, "domaininfo_list.json"), + "DomainInfo domains_data": loadFixture(t, "domaininfo_domains_data.json"), + "Email list_pops_with_disk": loadFixture(t, "email_list_pops.json"), + "Email list_forwarders": loadFixture(t, "email_forwarders.json"), + "Email list_auto_responders": loadFixture(t, "email_autoresponders.json"), + "Mysql list_databases": wrapUAPI(`[]`), + "Mysql list_users": wrapUAPI(`[]`), + "Ftp list_ftp_with_disk": loadFixture(t, "ftp_list.json"), + "SSL list_certs": loadFixture(t, "ssl_list_certs.json"), + "LangPHP php_get_vhost_versions": loadFixture(t, "php_vhost_versions.json"), + "DNS parse_zone": loadFixture(t, "dns_parse_zone.json"), + }} + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + found := false + for _, z := range result.Source.DNS.Zones { + if z.Available && z.Method == "uapi" { + found = true + if z.SourceFunction != "DNS::parse_zone" { + t.Errorf("zone %s source_function = %q", z.Zone, z.SourceFunction) + } + } + } + if !found { + t.Error("expected at least one zone with method=uapi") + } +} + +func TestCollectDNSUAPIFailFallbackAPI2(t *testing.T) { + uapiFail := []byte(`{"result":{"data":null,"errors":["The function \"parse_zone\" does not exist in module \"DNS\"."],"status":0}}`) + runner := &fakeRunner{responses: map[string][]byte{ + "DomainInfo list_domains": loadFixture(t, "domaininfo_list.json"), + "DomainInfo domains_data": loadFixture(t, "domaininfo_domains_data.json"), + "Email list_pops_with_disk": loadFixture(t, "email_list_pops.json"), + "Email list_forwarders": loadFixture(t, "email_forwarders.json"), + "Email list_auto_responders": loadFixture(t, "email_autoresponders.json"), + "Mysql list_databases": wrapUAPI(`[]`), + "Mysql list_users": wrapUAPI(`[]`), + "Ftp list_ftp_with_disk": loadFixture(t, "ftp_list.json"), + "SSL list_certs": loadFixture(t, "ssl_list_certs.json"), + "LangPHP php_get_vhost_versions": loadFixture(t, "php_vhost_versions.json"), + "DNS parse_zone": uapiFail, + "ZoneEdit fetchzone_records": loadFixture(t, "dns_fetchzone_records.json"), + }} + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + for _, z := range result.Source.DNS.Zones { + if z.Available && z.Method != "api2" { + t.Errorf("zone %s should have method=api2, got %s (UAPI should have failed)", z.Zone, z.Method) + } + } +} + +func TestCollectDNSBothFailWarning(t *testing.T) { + uapiFail := []byte(`{"result":{"data":null,"errors":["DNS not available"],"status":0}}`) + api2Fail := []byte(`{"cpanelresult":{"data":[],"event":{"result":0},"error":"Zone not found"}}`) + runner := &fakeRunner{responses: map[string][]byte{ + "DomainInfo list_domains": loadFixture(t, "domaininfo_list.json"), + "DomainInfo domains_data": loadFixture(t, "domaininfo_domains_data.json"), + "Email list_pops_with_disk": loadFixture(t, "email_list_pops.json"), + "Email list_forwarders": loadFixture(t, "email_forwarders.json"), + "Email list_auto_responders": loadFixture(t, "email_autoresponders.json"), + "Mysql list_databases": wrapUAPI(`[]`), + "Mysql list_users": wrapUAPI(`[]`), + "Ftp list_ftp_with_disk": loadFixture(t, "ftp_list.json"), + "SSL list_certs": loadFixture(t, "ssl_list_certs.json"), + "LangPHP php_get_vhost_versions": loadFixture(t, "php_vhost_versions.json"), + "DNS parse_zone": uapiFail, + "ZoneEdit fetchzone_records": api2Fail, + }} + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect should not fail on DNS error: %v", err) + } + if result.Source.DNS.Available { + t.Error("DNS should be unavailable when both UAPI and API2 fail") + } + hasWarning := false + for _, z := range result.Source.DNS.Zones { + if len(z.Warnings) > 0 { + hasWarning = true + } + if z.Method != "unavailable" { + t.Errorf("zone %s method should be unavailable, got %s", z.Zone, z.Method) + } + } + if !hasWarning { + t.Error("expected warnings on failed DNS zones") + } +} + +func TestCollectDNSFailNotFatal(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{ + "DomainInfo list_domains": loadFixture(t, "domaininfo_list.json"), + "DomainInfo domains_data": loadFixture(t, "domaininfo_domains_data.json"), + "Email list_pops_with_disk": loadFixture(t, "email_list_pops.json"), + "Email list_forwarders": loadFixture(t, "email_forwarders.json"), + "Email list_auto_responders": loadFixture(t, "email_autoresponders.json"), + "Mysql list_databases": wrapUAPI(`[]`), + "Mysql list_users": wrapUAPI(`[]`), + "Ftp list_ftp_with_disk": loadFixture(t, "ftp_list.json"), + "SSL list_certs": loadFixture(t, "ssl_list_certs.json"), + "LangPHP php_get_vhost_versions": loadFixture(t, "php_vhost_versions.json"), + }} + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect should not fail: %v", err) + } + if len(result.Source.Domains) == 0 { + t.Error("Domains should still be collected") + } + if result.Source.FTP.Available != true { + t.Error("FTP should still be available") + } +} + +func TestCollectDNSSkipsSubdomains(t *testing.T) { + runner := newFakeRunnerFromFixtures(t) + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + for _, z := range result.Source.DNS.Zones { + for _, d := range result.Source.Domains { + if d.Name == z.Zone && d.Type == "sub" { + t.Errorf("subdomain %s should not appear as a DNS zone", z.Zone) + } + } + } +} + +// --------------------------------------------------------------------------- +// Cron collection tests +// --------------------------------------------------------------------------- + +func TestCollectCronPresent(t *testing.T) { + runner := newFakeRunnerFromFixtures(t) + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + cron := result.Source.Cron + if !cron.Available { + t.Fatal("cron should be available") + } + if cron.Method != "ssh_crontab_l" { + t.Errorf("method = %q, want ssh_crontab_l", cron.Method) + } + if cron.SourceCommand != "crontab -l" { + t.Errorf("source_command = %q", cron.SourceCommand) + } + if len(cron.Jobs) != 3 { + t.Errorf("jobs = %d, want 3 (2 enabled + 1 disabled)", len(cron.Jobs)) + } + if cron.DisabledJobsCount != 1 { + t.Errorf("disabled = %d, want 1", cron.DisabledJobsCount) + } + if cron.CommentsCount != 1 { + t.Errorf("comments = %d, want 1", cron.CommentsCount) + } + if len(cron.Environment) != 1 || cron.Environment[0].Name != "MAILTO" { + t.Errorf("environment = %+v", cron.Environment) + } + for _, j := range cron.Jobs { + if contains(j.CommandRedacted, "supersecret") { + t.Errorf("secret leaked in job command: %q", j.CommandRedacted) + } + } +} + +func TestCollectCronNoCrontabForUser(t *testing.T) { + runner := newFakeRunnerFromFixtures(t) + runner.responses["crontab -l"] = []byte("no crontab for u\n__CRONTAB_RC:1__\n") + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + cron := result.Source.Cron + if !cron.Available { + t.Error("empty crontab is still 'available'") + } + if len(cron.Jobs) != 0 { + t.Errorf("jobs = %d, want 0", len(cron.Jobs)) + } + if len(cron.Warnings) == 0 { + t.Error("expected light warning for empty crontab") + } +} + +func TestCollectCronErrorNotFatal(t *testing.T) { + runner := newFakeRunnerFromFixtures(t) + delete(runner.responses, "crontab -l") + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("cron failure must not block inventory: %v", err) + } + cron := result.Source.Cron + if cron.Available { + t.Error("cron should be unavailable") + } + if cron.Method != "unavailable" { + t.Errorf("method = %q, want unavailable", cron.Method) + } + if len(cron.Errors) == 0 { + t.Error("hard failure must populate errors") + } + if len(result.Source.Domains) == 0 { + t.Error("rest of inventory must still be collected") + } +} + +func TestCollectCronNoNullArrays(t *testing.T) { + runner := newFakeRunnerFromFixtures(t) + delete(runner.responses, "crontab -l") + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + cron := result.Source.Cron + if cron.Jobs == nil || cron.Environment == nil || cron.Warnings == nil || cron.Errors == nil { + t.Errorf("cron slices must never be nil: %+v", cron) + } +} + +func TestCollectDNSNoNullArrays(t *testing.T) { + runner := newFakeRunnerFromFixtures(t) + ctx := context.Background() + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect: %v", err) + } + for _, z := range result.Source.DNS.Zones { + if z.Records == nil { + t.Errorf("zone %s records is nil, want empty slice", z.Zone) + } + if z.Warnings == nil { + t.Errorf("zone %s warnings is nil, want empty slice", z.Zone) + } + if z.Errors == nil { + t.Errorf("zone %s errors is nil, want empty slice", z.Zone) + } + } +} + +func TestCollectMailboxWarningNotFatal(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{ + "DomainInfo list_domains": loadFixture(t, "domaininfo_list.json"), + "DomainInfo domains_data": loadFixture(t, "domaininfo_domains_data.json"), + "Mysql list_databases": wrapUAPI(`[]`), + "Mysql list_users": wrapUAPI(`[]`), + // list_filters succeeds while the mailbox list fails: the + // filters section must surface the narrowed scope (PR 7E). + "Email list_filters": wrapUAPI(`[]`), + }} + ctx := context.Background() + + result, err := Collect(ctx, runner, nil, HostInfo{User: "u", Host: "h"}, HostInfo{}) + if err != nil { + t.Fatalf("Collect should not fail on mailbox error: %v", err) + } + if len(result.Source.Mailboxes) != 0 { + t.Errorf("Mailboxes should be empty, got %d", len(result.Source.Mailboxes)) + } + found := false + for _, w := range result.Source.Warnings { + if contains(w, "mailbox") || contains(w, "email") || contains(w, "Email") { + found = true + break + } + } + if !found { + t.Errorf("expected a warning about mailboxes, got: %v", result.Source.Warnings) + } + ef := result.Source.EmailFilters + if !ef.Available { + t.Fatalf("email filters section = %+v, want available", ef.ConfigSection) + } + scoped := false + for _, w := range ef.Warnings { + if contains(w, "account-level") { + scoped = true + } + } + if !scoped { + t.Errorf("email filters must warn about account-level-only scope, got: %v", ef.Warnings) + } +} + +// FetchDNSZone (PR 6C) is the per-zone fetch extracted from collectDNS so +// `dns verify` re-fetches destination zones with the exact collector +// semantics: UAPI parse_zone first, API2 fallback, unavailable-with-warning. +func TestFetchDNSZoneUAPI(t *testing.T) { + runner := &fakeRunner{responses: map[string][]byte{ + "DNS parse_zone": loadFixture(t, "dns_parse_zone.json"), + }} + zr := FetchDNSZone(context.Background(), runner, "example.com") + if !zr.Available || zr.Method != "uapi" || zr.SourceFunction != "DNS::parse_zone" { + t.Fatalf("zone = %+v, want available uapi DNS::parse_zone", zr) + } + if zr.Zone != "example.com" { + t.Errorf("zone name = %q", zr.Zone) + } + if len(zr.Records) == 0 { + t.Error("expected records from the fixture") + } +} + +func TestFetchDNSZoneFallbackAPI2(t *testing.T) { + uapiFail := []byte(`{"result":{"data":null,"errors":["The function \"parse_zone\" does not exist in module \"DNS\"."],"status":0}}`) + runner := &fakeRunner{responses: map[string][]byte{ + "DNS parse_zone": uapiFail, + "ZoneEdit fetchzone_records": loadFixture(t, "dns_fetchzone_records.json"), + }} + zr := FetchDNSZone(context.Background(), runner, "example.com") + if !zr.Available || zr.Method != "api2" || zr.SourceFunction != "ZoneEdit::fetchzone_records" { + t.Fatalf("zone = %+v, want available api2 ZoneEdit::fetchzone_records", zr) + } + if len(zr.Records) == 0 { + t.Error("expected records from the fixture") + } +} + +func TestFetchDNSZoneUnavailable(t *testing.T) { + uapiFail := []byte(`{"result":{"data":null,"errors":["boom"],"status":0}}`) + runner := &fakeRunner{responses: map[string][]byte{ + "DNS parse_zone": uapiFail, + }} + zr := FetchDNSZone(context.Background(), runner, "example.com") + if zr.Available || zr.Method != "unavailable" { + t.Fatalf("zone = %+v, want unavailable", zr) + } + if len(zr.Warnings) != 1 || !contains(zr.Warnings[0], "example.com") { + t.Errorf("warnings = %v, want one naming the zone", zr.Warnings) + } +} + +// TestCollectCronArtifactCarriesNoRawSecret routes through the REAL collectCron +// with a crontab whose job authenticates with secure= and whose env +// carries the token. It guards BOTH the collector mapping (no raw fields +// populated, marker stays true) AND the serialization (json:"-" keeps the raw +// out of the artifact even under hostile field repopulation). Fails on the +// pre-fix code (command_clear/raw_line populated, token serialized). +func TestCollectCronArtifactCarriesNoRawSecret(t *testing.T) { + const token = "s3cr3tTok3nAABBCCDD" + runner := &fakeRunner{responses: map[string][]byte{ + "crontab -l": []byte( + "0 5 * * * /usr/bin/curl https://api.example/hook?secure=" + token + "\n" + + "MYSECRET=" + token + "\n" + + "__CRONTAB_RC:0__\n"), + }} + + sec := collectCron(context.Background(), runner) + + if len(sec.Jobs) == 0 { + t.Fatalf("expected at least one parsed job, got %+v", sec) + } + for _, j := range sec.Jobs { + if j.CommandClear != "" || j.RawLine != "" { + t.Errorf("raw cron fields populated: clear=%q raw=%q", j.CommandClear, j.RawLine) + } + if !j.CommandCollected { + t.Error("CommandCollected must stay true for a real parsed job") + } + if strings.Contains(j.CommandRedacted, token) { + t.Errorf("token leaked in CommandRedacted: %q", j.CommandRedacted) + } + if !strings.Contains(j.CommandRedacted, "secure=[REDACTED]") { + t.Errorf("expected secure=[REDACTED] in %q", j.CommandRedacted) + } + } + for _, e := range sec.Environment { + if e.ValueClear != "" { + t.Errorf("raw env value present: clear=%q", e.ValueClear) + } + if strings.Contains(e.ValueRedacted, token) { + t.Errorf("token leaked in ValueRedacted: %q", e.ValueRedacted) + } + } + + // On-disk proof: hostile repopulation of the Go fields must NOT reach the + // serialized artifact because the raw fields are tagged json:"-". + sec.Jobs[0].CommandClear = token + sec.Jobs[0].RawLine = token + if len(sec.Environment) > 0 { + sec.Environment[0].ValueClear = token + } + blob, err := json.Marshal(sec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if bytes.Contains(blob, []byte(token)) { + t.Fatalf("token leaked into serialized cron section: %s", blob) + } + for _, k := range []string{`"command_clear"`, `"raw_line"`, `"value_clear"`} { + if bytes.Contains(blob, []byte(k)) { + t.Errorf("raw json key %s serialized despite json:\"-\"", k) + } + } +} diff --git a/internal/accountinventory/coverage.go b/internal/accountinventory/coverage.go new file mode 100644 index 00000000..fce884a7 --- /dev/null +++ b/internal/accountinventory/coverage.go @@ -0,0 +1,113 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +// PR 1A - coverage manifest. "What we do not see must declare itself": the +// checklist embeds a static registry of EVERY account area the tool knows +// about, each with its coverage state, so an uncollected area is a visible +// line in the operator's report instead of a silent absence. +// +// The manifest is purely DECLARATIVE. It never creates manual actions, never +// contributes to the summary counts and never changes the overall verdict - +// its whole job is to make the boundary of the tool's sight explicit. + +// CoverageState classifies how the tool relates to an account area. +type CoverageState string + +const ( + // CoverageCovered: the area is a real inventoried checklist section - + // collected, diffed, policy-classified. + CoverageCovered CoverageState = "covered" + // CoverageRootOnly: the area cannot be read at all with account-level + // access; it surfaces as an explicit root-only checklist section. + CoverageRootOnly CoverageState = "root_only" + // CoverageNotCollected: the area is account-accessible but the inventory + // does not collect it (yet). Declared here so it can never be silently + // absent; collectors are Fase 1B/1C work. + CoverageNotCollected CoverageState = "not_collected" +) + +// CoverageArea is one registry entry. Area names of covered/root_only entries +// are EXACTLY the checklist section names (pinned by tests); not_collected +// entries carry a note explaining what is at stake. +type CoverageArea struct { + Area string `json:"area"` + State CoverageState `json:"state"` + Note string `json:"note,omitempty"` +} + +// coverageRegistry is the single source of truth. Order: the checklist +// sections in their fixed order, then the not-collected areas alphabetically. +// +// KEEP IN LOCKSTEP with checklistSectionOrder (pinned by +// TestCoverageRegistryLockstepWithChecklistSections): a new inventoried +// section must flip its entry to covered here, and a new collector removes +// its area from the not_collected block. +var coverageRegistry = []CoverageArea{ + {Area: "domains", State: CoverageCovered}, + {Area: "web_files", State: CoverageCovered}, + {Area: "mailboxes", State: CoverageCovered}, + {Area: "databases", State: CoverageCovered}, + {Area: "forwarders", State: CoverageCovered}, + {Area: "autoresponders", State: CoverageCovered}, + {Area: "ftp", State: CoverageCovered}, + {Area: "ssl", State: CoverageCovered}, + {Area: "php", State: CoverageCovered}, + {Area: "dns", State: CoverageCovered}, + {Area: "cron", State: CoverageCovered}, + {Area: "email_routing", State: CoverageCovered}, + {Area: "default_address", State: CoverageCovered}, + {Area: "email_filters", State: CoverageCovered}, + {Area: "redirects", State: CoverageCovered}, + {Area: "quota_package", State: CoverageRootOnly, + Note: "package assignment, quotas and bandwidth limits are WHM territory"}, + {Area: "server_level_config", State: CoverageRootOnly, + Note: "PHP handlers, web server, firewall and system crons are not visible with account-level access"}, + + {Area: "api_tokens", State: CoverageNotCollected, + Note: "API token NAMES are listable user-level; secrets are never retrievable - historical-dossier material"}, + // autoresponder_bodies: collected since PR 2B-2 (get_auto_responder per + // listed address) - folded into the covered "autoresponders" section. + // email_filter_rules: collected since PR 2B-3 (get_filter per listed + // filter, option A: rules in clear) - folded into the covered + // "email_filters" section. + {Area: "boxtrapper", State: CoverageNotCollected, + Note: "BoxTrapper enable state and configuration"}, + {Area: "contact_info", State: CoverageNotCollected, + Note: "account contact addresses and notification preferences"}, + {Area: "directory_privacy", State: CoverageNotCollected, + Note: "password-protected directories (~/.htpasswds) - the protection passwords are at stake on transfer"}, + {Area: "domain_aliases", State: CoverageNotCollected, + Note: "parked/alias domains as a dedicated field - today folded into the domains listing"}, + {Area: "git_repositories", State: CoverageNotCollected, + Note: "cPanel-registered git repositories (working trees travel with the home transfer, the registrations do not)"}, + {Area: "hotlink_protection", State: CoverageNotCollected, + Note: "hotlink protection configuration"}, + {Area: "leech_protection", State: CoverageNotCollected, + Note: "leech protection configuration"}, + {Area: "mailbox_quota_limits", State: CoverageNotCollected, + Note: "per-mailbox quota LIMITS - usage is collected, the configured limit is not"}, + {Area: "mailing_lists", State: CoverageNotCollected, + Note: "Mailman mailing lists (member rosters are root-only and cannot be migrated user-level)"}, + {Area: "mime_handlers", State: CoverageNotCollected, + Note: "custom MIME types and Apache handlers"}, + {Area: "passenger_apps", State: CoverageNotCollected, + Note: "registered Passenger/Node/Python applications - files travel with the transfer, the registrations do not"}, + {Area: "spamassassin", State: CoverageNotCollected, + Note: "SpamAssassin enable state and user_prefs (~/.spamassassin is outside the docroot copy)"}, + {Area: "ssh_keys", State: CoverageNotCollected, + Note: "SSH key METADATA (names/fingerprints only - private keys are never collected)"}, + {Area: "team_users", State: CoverageNotCollected, + Note: "cPanel Team user accounts (their passwords cannot be migrated)"}, + {Area: "webdisk_accounts", State: CoverageNotCollected, + Note: "WebDisk accounts (passwords would need regeneration on the destination)"}, +} + +// CoverageAreas returns the full manifest, as a copy: callers can embed or +// mutate the result without corrupting the registry. +func CoverageAreas() []CoverageArea { + out := make([]CoverageArea, len(coverageRegistry)) + copy(out, coverageRegistry) + return out +} diff --git a/internal/accountinventory/coverage_test.go b/internal/accountinventory/coverage_test.go new file mode 100644 index 00000000..a857dfc3 --- /dev/null +++ b/internal/accountinventory/coverage_test.go @@ -0,0 +1,154 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// PR 1A - coverage manifest. The checklist must declare EVERY area the tool +// knows about with its coverage state, so an area we do not collect can never +// be silently absent from the operator's picture. The manifest is purely +// declarative: it must never create actions, never touch the summary counts, +// and never change the overall verdict. + +func TestCoverageRegistryLockstepWithChecklistSections(t *testing.T) { + areas := CoverageAreas() + byName := map[string]CoverageArea{} + for _, a := range areas { + if _, dup := byName[a.Area]; dup { + t.Fatalf("duplicate coverage area %q", a.Area) + } + byName[a.Area] = a + } + // Every checklist section must be declared in the registry with the + // matching state - adding a section without updating the registry fails. + for _, name := range checklistSectionOrder { + a, ok := byName[name] + if !ok { + t.Fatalf("checklist section %q missing from the coverage registry", name) + } + want := CoverageCovered + if name == "quota_package" || name == "server_level_config" { + want = CoverageRootOnly + } + if a.State != want { + t.Errorf("area %q state = %q, want %q", name, a.State, want) + } + } + // And the registry must not invent covered/root_only areas that are not + // checklist sections (not_collected areas are exactly the extra ones). + sections := map[string]bool{} + for _, name := range checklistSectionOrder { + sections[name] = true + } + for _, a := range areas { + switch a.State { + case CoverageCovered, CoverageRootOnly: + if !sections[a.Area] { + t.Errorf("area %q has state %q but is not a checklist section", a.Area, a.State) + } + case CoverageNotCollected: + if sections[a.Area] { + t.Errorf("area %q is a checklist section but declared not_collected", a.Area) + } + if a.Note == "" { + t.Errorf("not_collected area %q must carry a note explaining what is at stake", a.Area) + } + default: + t.Errorf("area %q has invalid state %q", a.Area, a.State) + } + } +} + +func TestCoverageRegistryIsDeterministicAndOrdered(t *testing.T) { + a1, a2 := CoverageAreas(), CoverageAreas() + if len(a1) == 0 { + t.Fatal("coverage registry is empty") + } + for i := range a1 { + if a1[i] != a2[i] { + t.Fatalf("registry not deterministic at %d: %+v vs %+v", i, a1[i], a2[i]) + } + } + // CoverageAreas must return a copy: mutating it must not corrupt the registry. + a1[0].Note = "mutated" + if fresh := CoverageAreas(); fresh[0].Note == "mutated" { + t.Fatal("CoverageAreas must return a copy of the registry, not the backing slice") + } + // not_collected areas are sorted by name (deterministic docs/diffs). + var nc []string + for _, a := range a2 { + if a.State == CoverageNotCollected { + nc = append(nc, a.Area) + } + } + if !sort.StringsAreSorted(nc) { + t.Errorf("not_collected areas must be sorted by name: %v", nc) + } + if len(nc) == 0 { + t.Error("registry must declare the known not-collected areas (they are the point of PR 1A)") + } +} + +func TestBuildChecklistEmbedsCoverageManifestWithoutSideEffects(t *testing.T) { + src := chkInventory("source", "src.example", "acct") + dest := chkInventory("destination", "dest.example", "acct") + c := BuildChecklist(chkInput(src, dest, nil, nil)) + + want := CoverageAreas() + if len(c.CoverageManifest) != len(want) { + t.Fatalf("manifest has %d areas, want %d", len(c.CoverageManifest), len(want)) + } + for i := range want { + if c.CoverageManifest[i] != want[i] { + t.Errorf("manifest[%d] = %+v, want %+v", i, c.CoverageManifest[i], want[i]) + } + } + + // Purely declarative: no manual action and no section may originate from a + // not_collected area, and the summary must not count manifest entries. + ncState := map[string]bool{} + for _, a := range want { + if a.State == CoverageNotCollected { + ncState[a.Area] = true + } + } + for _, ma := range c.ManualActions { + if ncState[ma.Section] { + t.Errorf("manual action %s references not_collected area %q - the manifest must not generate actions", ma.ID, ma.Section) + } + } + if len(c.Sections) != len(checklistSectionOrder) { + t.Errorf("sections = %d, want %d - the manifest must not add sections", len(c.Sections), len(checklistSectionOrder)) + } +} + +func TestChecklistMarkdownRendersCoverageManifest(t *testing.T) { + src := chkInventory("source", "src.example", "acct") + dest := chkInventory("destination", "dest.example", "acct") + c := BuildChecklist(chkInput(src, dest, nil, nil)) + + path := filepath.Join(t.TempDir(), "migration_checklist.md") + if err := WriteChecklistMarkdown(path, c); err != nil { + t.Fatalf("WriteChecklistMarkdown: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + md := string(b) + if !strings.Contains(md, "## Coverage") { + t.Fatalf("markdown missing the Coverage section") + } + for _, a := range CoverageAreas() { + if !strings.Contains(md, a.Area) || !strings.Contains(md, string(a.State)) { + t.Errorf("markdown Coverage table missing area %q (state %s)", a.Area, a.State) + } + } +} diff --git a/internal/accountinventory/cronapply.go b/internal/accountinventory/cronapply.go new file mode 100644 index 00000000..541a94d4 --- /dev/null +++ b/internal/accountinventory/cronapply.go @@ -0,0 +1,89 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +// Cron apply op statuses. +const ( + CronOpApplied = "applied" + CronOpSkipped = "skipped" + CronOpManual = "manual" + CronOpFailed = "failed" + CronOpRefused = "refused_precondition" +) + +// CronApplyOpResult is one plan op with its apply outcome. +type CronApplyOpResult struct { + CronPlanOp + Status string `json:"status"` + StatusReason string `json:"status_reason,omitempty"` +} + +// CronApplySummary counts results by status. +type CronApplySummary struct { + Applied int `json:"applied"` + Skipped int `json:"skipped"` + Manual int `json:"manual"` + Failed int `json:"failed"` + Refused int `json:"refused_precondition"` +} + +// CronApplyReport records what one `cron apply` (or rollback) run +// actually did. The backup records the pre-write crontab state; the +// report records the path AND sha256 of its backup -- bidirectional +// pairing, because the rollback needs the report to know which ops were +// ACTUALLY performed. +type CronApplyReport struct { + Mode string `json:"mode"` // "cron-apply-report" + FormatVersion int `json:"format_version"` + RunMode string `json:"run_mode"` // "apply" | "rollback" + GeneratedAt string `json:"generated_at"` + DestinationUser string `json:"destination_user"` + PlanFile string `json:"plan_file,omitempty"` + PlanSHA256 string `json:"plan_sha256,omitempty"` + BackupFile string `json:"backup_file,omitempty"` + BackupSHA256 string `json:"backup_sha256,omitempty"` + // BackupNote documents WHY no backup exists when BackupFile is empty + // (e.g. zero writes decided) -- an empty path with no note is invalid. + BackupNote string `json:"backup_note,omitempty"` + Results []CronApplyOpResult `json:"results"` + // InstalledCrontab is the full crontab installed (for verify). + InstalledCrontab string `json:"installed_crontab,omitempty"` + Summary CronApplySummary `json:"summary"` +} + +// SummarizeCronResults recomputes the summary from the results. +func SummarizeCronResults(results []CronApplyOpResult) CronApplySummary { + var s CronApplySummary + for _, r := range results { + switch r.Status { + case CronOpApplied: + s.Applied++ + case CronOpSkipped: + s.Skipped++ + case CronOpManual: + s.Manual++ + case CronOpFailed: + s.Failed++ + case CronOpRefused: + s.Refused++ + } + } + return s +} + +// CronApplyBackup is the pre-write state of the destination crontab. +// No backup file => no write. +type CronApplyBackup struct { + Mode string `json:"mode"` // "cron-apply-backup" + FormatVersion int `json:"format_version"` + GeneratedAt string `json:"generated_at"` + DestinationUser string `json:"destination_user"` + PlanFile string `json:"plan_file,omitempty"` + PlanSHA256 string `json:"plan_sha256,omitempty"` + // ReportFile is the path of the paired apply report (recorded at + // backup time -- the report path is known before the first write). + ReportFile string `json:"report_file"` + RawCrontab string `json:"raw_crontab"` // verbatim crontab -l output + CrontabSHA256 string `json:"crontab_sha256"` // for atomic guard +} diff --git a/internal/accountinventory/cronapply_write.go b/internal/accountinventory/cronapply_write.go new file mode 100644 index 00000000..6fb739ee --- /dev/null +++ b/internal/accountinventory/cronapply_write.go @@ -0,0 +1,110 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// WriteCronApplyBackupJSON writes the pre-write backup. Mode 0600: it holds +// the verbatim crontab content. +func WriteCronApplyBackupJSON(path string, backup CronApplyBackup) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + data, err := json.MarshalIndent(backup, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal cron backup: %w", err) + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} + +// WriteCronApplyReportJSON writes the machine-readable apply report. +func WriteCronApplyReportJSON(path string, report CronApplyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal cron apply report: %w", err) + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} + +// WriteCronApplyReportMarkdown writes the human-readable apply report. +// Failures and refusals come first: they are what the operator must act on. +func WriteCronApplyReportMarkdown(path string, r CronApplyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + title := "Cron Apply Report" + if r.RunMode == "rollback" { + title = "Cron Rollback Report" + } + fmt.Fprintf(&sb, "# %s\n\n", title) + fmt.Fprintf(&sb, "- **Run mode**: %s\n", r.RunMode) + fmt.Fprintf(&sb, "- **Destination account**: %s\n", r.DestinationUser) + if r.PlanFile != "" { + fmt.Fprintf(&sb, "- **Plan**: %s (sha256 %s)\n", r.PlanFile, r.PlanSHA256) + } + if r.BackupFile != "" { + fmt.Fprintf(&sb, "- **Backup**: %s (sha256 %s)\n", r.BackupFile, r.BackupSHA256) + } else if r.BackupNote != "" { + fmt.Fprintf(&sb, "- **Backup**: none, %s\n", r.BackupNote) + } + fmt.Fprintf(&sb, "- **Generated**: %s\n\n", r.GeneratedAt) + fmt.Fprintf(&sb, "**Summary**: %d applied, %d skipped, %d manual, %d failed, %d refused (precondition)\n\n", + r.Summary.Applied, r.Summary.Skipped, r.Summary.Manual, + r.Summary.Failed, r.Summary.Refused) + + order := []string{CronOpFailed, CronOpRefused, CronOpApplied, CronOpManual, CronOpSkipped} + for _, status := range order { + var rows []CronApplyOpResult + for _, res := range r.Results { + if res.Status == status { + rows = append(rows, res) + } + } + if len(rows) == 0 { + continue + } + fmt.Fprintf(&sb, "## %s (%d)\n\n", strings.ToUpper(strings.ReplaceAll(status, "_", " ")), len(rows)) + sb.WriteString("| Section | Key | Detail | Note |\n|---------|-----|--------|------|\n") + for _, res := range rows { + detail := cronOpDetail(res.CronPlanOp) + note := res.StatusReason + if note == "" { + note = res.Reason + } + fmt.Fprintf(&sb, "| %s | %s | %s | %s |\n", + mdCell(res.Section, 20), mdCell(res.Key, 60), mdCell(detail, 90), mdCell(note, 110)) + } + sb.WriteString("\n") + } + return os.WriteFile(path, []byte(sb.String()), 0o600) +} + +// cronOpDetail renders the detail column for a cron apply op. +func cronOpDetail(op CronPlanOp) string { + switch op.Action { + case CronActionCreate: + if op.PathAdapted { + return fmt.Sprintf("create (path-adapted) %s", op.SourceValue) + } + return fmt.Sprintf("create %s", op.SourceValue) + case CronActionSkip: + return fmt.Sprintf("skip, already present %s", op.DestinationValue) + case CronActionManual: + return fmt.Sprintf("manual %s", op.SourceValue) + default: + return op.Action + } +} diff --git a/internal/accountinventory/cronplan.go b/internal/accountinventory/cronplan.go new file mode 100644 index 00000000..60dbb025 --- /dev/null +++ b/internal/accountinventory/cronplan.go @@ -0,0 +1,337 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" +) + +// Cron apply plan (PR 2A). BuildCronPlan is fully offline: it consumes +// two normalized inventories and produces a reviewable plan of what +// `cron apply` would install into the DESTINATION account's crontab. +// It never connects anywhere and never generates delete ops for +// destination-only entries; the design lives in PR2A_CRON_APPLY_DESIGN.md. +// +// The write primitive is SSH `crontab -` which REPLACES the entire +// crontab: the plan records the complete expected destination crontab +// for the atomic guard, plus per-line ops for the human review. + +const ( + CronActionCreate = "create" + CronActionSkip = "skip" + CronActionManual = "manual" +) + +const ( + CronSectionJobs = "cron_jobs" + CronSectionEnv = "cron_env" +) + +// CronPlanOp is the decision for one crontab line. +type CronPlanOp struct { + Section string `json:"section"` + Action string `json:"action"` + Key string `json:"key"` + Reason string `json:"reason,omitempty"` + + // The installable crontab line (raw, un-redacted). For create ops, + // this is the line that will be appended to the destination crontab. + // For skip ops, it records the matched line for verify. + Line string `json:"line,omitempty"` + // SourceLine is the original source line (before path adaptation). + SourceLine string `json:"source_line,omitempty"` + // PathAdapted is true when /home// was replaced with + // /home// in the installable line. + PathAdapted bool `json:"path_adapted,omitempty"` + + // Display context (redacted). + SourceValue string `json:"source_value,omitempty"` + DestinationValue string `json:"destination_value,omitempty"` +} + +// CronPlanInfo is a destination-only line: listed, never deleted. +type CronPlanInfo struct { + Section string `json:"section"` + Key string `json:"key"` + Value string `json:"value"` +} + +type CronPlanSummary struct { + Create int `json:"create"` + Skip int `json:"skip"` + Manual int `json:"manual"` + Informational int `json:"informational"` +} + +type CronApplyPlan struct { + Mode string `json:"mode"` + FormatVersion int `json:"format_version"` + GeneratedAt string `json:"generated_at"` + SourceFile string `json:"source_file,omitempty"` + SourceSHA256 string `json:"source_sha256,omitempty"` + DestinationFile string `json:"destination_file,omitempty"` + DestinationSHA256 string `json:"destination_sha256,omitempty"` + SourceUser string `json:"source_user"` + DestinationUser string `json:"destination_user"` + Ops []CronPlanOp `json:"ops"` + Informational []CronPlanInfo `json:"informational,omitempty"` + Summary CronPlanSummary `json:"summary"` + + // PlanTimeDestCrontab is the SHA256 of the raw destination crontab + // at plan time. The apply guard compares this against the current + // crontab before writing - any change requires a re-plan. + PlanTimeDestCrontab string `json:"plan_time_dest_crontab_sha256,omitempty"` +} + +// cronJobKey returns the diff-compatible key for a cron job (the redacted +// command - deterministic, human-readable, matches the diff output). +func cronJobKey(j CronJobEntry) string { + return j.CommandRedacted +} + +// cronScheduleStr renders the schedule fields as a single string. +func cronScheduleStr(j CronJobEntry) string { + if j.Type == "macro" { + return j.Macro + } + return strings.Join([]string{j.Minute, j.Hour, j.DayOfMonth, j.Month, j.DayOfWeek}, " ") +} + +// cronJobLine reconstructs the crontab line for the plan preview. In the +// read-only path the raw fields are never populated (secret hygiene), so this +// falls back to the REDACTED command: an install-shaped, credential-masked +// preview, never installed as-is. The Batch-D apply writer builds the real +// installable line from a freshly collected crontab, not from this preview. +func cronJobLine(j CronJobEntry) string { + if j.RawLine != "" { + return j.RawLine + } + cmd := j.CommandClear + if cmd == "" { + cmd = j.CommandRedacted + } + if j.Type == "macro" { + return j.Macro + " " + cmd + } + return cronScheduleStr(j) + " " + cmd +} + +// cronEnvLine reconstructs an env line for the plan preview. Falls back to the +// redacted value when the clear value is absent (read-only path). +func cronEnvLine(e CronEnvEntry) string { + v := e.ValueClear + if v == "" { + v = e.ValueRedacted + } + return e.Name + "=" + v +} + +// adaptCronPath replaces /home// with /home// in a +// crontab line, if applicable. +func adaptCronPath(line, srcUser, destUser string) (adapted string, changed bool) { + if srcUser == destUser || srcUser == "" || destUser == "" { + return line, false + } + old := "/home/" + srcUser + "/" + new := "/home/" + destUser + "/" + if !strings.Contains(line, old) { + return line, false + } + return strings.ReplaceAll(line, old, new), true +} + +// cronJobsEquivalent reports whether two jobs are functionally identical: +// same schedule, same command, same enabled state. The clear-text branch is +// Batch-D-only (in-memory apply path): it fires solely when BOTH sides carry +// the raw command. The read-only path leaves the clear text empty and uses +// CommandSHA256, the hash of the redacted (secret-free) command, which is a +// faithful identity for same/different classification. +func cronJobsEquivalent(a, b CronJobEntry) bool { + if cronScheduleStr(a) != cronScheduleStr(b) || a.Enabled != b.Enabled { + return false + } + if a.CommandClear != "" && b.CommandClear != "" { + return a.CommandClear == b.CommandClear + } + return a.CommandSHA256 == b.CommandSHA256 +} + +// cronEnvsEquivalent reports whether two env entries match. The clear-text +// branch is Batch-D-only: it fires solely when BOTH sides carry the raw value. +// The read-only path compares ValueRedacted. Residual: two distinct sensitive +// vars both masking to "[REDACTED]" compare equal, yielding a conservative +// skip (never an unwanted write); pre-existing and unchanged. +func cronEnvsEquivalent(a, b CronEnvEntry) bool { + if a.Name != b.Name { + return false + } + if a.ValueClear != "" && b.ValueClear != "" { + return a.ValueClear == b.ValueClear + } + return a.ValueRedacted == b.ValueRedacted +} + +// BuildCronPlan computes the cron apply plan. +func BuildCronPlan(src, dest NormalizedInventory) CronApplyPlan { + plan := CronApplyPlan{ + Mode: "cron-apply-plan", + FormatVersion: 1, + SourceUser: src.Account.User, + DestinationUser: dest.Account.User, + Ops: []CronPlanOp{}, + } + + if !src.Cron.Available || !dest.Cron.Available { + return plan + } + + planCronJobs(&plan, src, dest) + planCronEnvs(&plan, src, dest) + + sort.Slice(plan.Ops, func(i, j int) bool { + a, b := plan.Ops[i], plan.Ops[j] + if a.Section != b.Section { + return a.Section == CronSectionEnv + } + return a.Key < b.Key + }) + + for _, op := range plan.Ops { + switch op.Action { + case CronActionCreate: + plan.Summary.Create++ + case CronActionSkip: + plan.Summary.Skip++ + case CronActionManual: + plan.Summary.Manual++ + } + } + plan.Summary.Informational = len(plan.Informational) + return plan +} + +func planCronJobs(plan *CronApplyPlan, src, dest NormalizedInventory) { + destByHash := map[string][]CronJobEntry{} + for _, j := range dest.Cron.Jobs { + destByHash[j.CommandSHA256] = append(destByHash[j.CommandSHA256], j) + } + + srcSeen := map[string]bool{} + for _, j := range src.Cron.Jobs { + if srcSeen[j.CommandSHA256] { + continue + } + srcSeen[j.CommandSHA256] = true + + op := CronPlanOp{ + Section: CronSectionJobs, + Key: cronJobKey(j), + SourceValue: cronScheduleStr(j) + " enabled=" + fmt.Sprintf("%v", j.Enabled), + } + + destJobs := destByHash[j.CommandSHA256] + if len(destJobs) > 0 { + d := destJobs[0] + op.DestinationValue = cronScheduleStr(d) + " enabled=" + fmt.Sprintf("%v", d.Enabled) + } + + switch { + case !j.CommandCollected: + op.Action = CronActionManual + op.Reason = "the source inventory carries no raw cron command (pre-2A artifact) - re-run --account-inventory on the source, then re-plan" + case !j.Enabled: + op.Action = CronActionManual + op.Reason = "disabled cron job - the operator decides whether to recreate it on the destination" + case len(destJobs) > 0 && cronJobsEquivalent(j, destJobs[0]): + op.Action = CronActionSkip + op.Line = cronJobLine(j) + case len(destJobs) > 0: + op.Action = CronActionManual + op.Reason = "the destination has a job with the same command but different schedule or enabled state - resolve by hand" + default: + line := cronJobLine(j) + adapted, changed := adaptCronPath(line, plan.SourceUser, plan.DestinationUser) + op.Action = CronActionCreate + op.SourceLine = line + op.Line = adapted + op.PathAdapted = changed + } + plan.Ops = append(plan.Ops, op) + } + + for _, j := range dest.Cron.Jobs { + if srcSeen[j.CommandSHA256] { + continue + } + plan.Informational = append(plan.Informational, CronPlanInfo{ + Section: CronSectionJobs, + Key: cronJobKey(j), + Value: cronScheduleStr(j), + }) + } +} + +func planCronEnvs(plan *CronApplyPlan, src, dest NormalizedInventory) { + destByName := map[string]CronEnvEntry{} + for _, e := range dest.Cron.Environment { + destByName[e.Name] = e + } + + srcSeen := map[string]bool{} + for _, e := range src.Cron.Environment { + if srcSeen[e.Name] { + continue + } + srcSeen[e.Name] = true + + op := CronPlanOp{ + Section: CronSectionEnv, + Key: e.Name, + SourceValue: e.ValueRedacted, + } + + d, onDest := destByName[e.Name] + if onDest { + op.DestinationValue = d.ValueRedacted + } + + switch { + case !e.ValueCollected: + op.Action = CronActionManual + op.Reason = "source env value not collected (pre-2A artifact) - re-run inventory" + case onDest && cronEnvsEquivalent(e, d): + op.Action = CronActionSkip + op.Line = cronEnvLine(e) + case onDest: + op.Action = CronActionManual + op.Reason = fmt.Sprintf("env %s has different value on destination (%s vs %s) - resolve by hand", e.Name, e.ValueRedacted, d.ValueRedacted) + default: + op.Action = CronActionCreate + op.Line = cronEnvLine(e) + } + plan.Ops = append(plan.Ops, op) + } + + for _, e := range dest.Cron.Environment { + if srcSeen[e.Name] { + continue + } + plan.Informational = append(plan.Informational, CronPlanInfo{ + Section: CronSectionEnv, + Key: e.Name, + Value: e.ValueRedacted, + }) + } +} + +// CronPlanDestCrontabHash computes the SHA256 of the raw destination +// crontab content, for the plan-time atomic guard. +func CronPlanDestCrontabHash(raw string) string { + h := sha256.Sum256([]byte(raw)) + return "sha256:" + hex.EncodeToString(h[:]) +} diff --git a/internal/accountinventory/cronplan_safety_test.go b/internal/accountinventory/cronplan_safety_test.go new file mode 100644 index 00000000..92689900 --- /dev/null +++ b/internal/accountinventory/cronplan_safety_test.go @@ -0,0 +1,58 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestCronPlanSourcesAreOfflineByConstruction pins the read-only, +// offline invariant of the cron plan-builder (PR 2A, mirror of +// emailplan_safety_test.go): no cronplan source may reference a cron +// write primitive or import a connecting package. +func TestCronPlanSourcesAreOfflineByConstruction(t *testing.T) { + writeCalls := []string{ + "crontab -", + "crontab -r", + "InstallCrontab", + } + forbiddenImports := []string{ + "internal/sshx", "internal/cpanel", "internal/migrate", + "golang.org/x/crypto/ssh", "\"net\"", "\"net/http\"", + } + files, err := filepath.Glob("cronplan*.go") + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no cronplan*.go files found - glob broken?") + } + for _, f := range files { + if f == "cronplan_safety_test.go" { + continue + } + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(b), "\n") { + code, _, _ := strings.Cut(strings.TrimSpace(line), "//") + for _, verb := range writeCalls { + if strings.Contains(code, verb) { + t.Errorf("%s:%d references cron write call %q in code - the plan is offline", + f, i+1, verb) + } + } + for _, imp := range forbiddenImports { + if strings.Contains(code, imp) { + t.Errorf("%s:%d imports/references %q - the plan builder is offline by construction", + f, i+1, imp) + } + } + } + } +} diff --git a/internal/accountinventory/cronplan_test.go b/internal/accountinventory/cronplan_test.go new file mode 100644 index 00000000..6978bd88 --- /dev/null +++ b/internal/accountinventory/cronplan_test.go @@ -0,0 +1,217 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "strings" + "testing" +) + +func cpInventory(side, user string) NormalizedInventory { + inv := NewEmptyInventory(user, "host", side) + inv.Cron.Available = true + inv.Cron.Method = "ssh_crontab_l" + return inv +} + +func findCronOp(t *testing.T, plan CronApplyPlan, section, key string) CronPlanOp { + t.Helper() + for _, op := range plan.Ops { + if op.Section == section && op.Key == key { + return op + } + } + t.Fatalf("no cron op for %s/%s in %d ops", section, key, len(plan.Ops)) + return CronPlanOp{} +} + +func TestCronPlanCreateActiveJob(t *testing.T) { + src := cpInventory("source", "srcacct") + src.Cron.Jobs = []CronJobEntry{ + {Type: "schedule", Minute: "0", Hour: "3", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/true", + CommandSHA256: "sha256:abc", + Enabled: true, CommandCollected: true}, + } + dest := cpInventory("destination", "destacct") + + p := BuildCronPlan(src, dest) + op := findCronOp(t, p, CronSectionJobs, "/usr/bin/true") + if op.Action != CronActionCreate { + t.Fatalf("active source-only job: action = %q, want create", op.Action) + } + if op.Line == "" { + t.Error("create op must carry the installable line") + } +} + +func TestCronPlanSkipIdenticalJob(t *testing.T) { + job := CronJobEntry{ + Type: "schedule", Minute: "0", Hour: "3", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/true", + CommandSHA256: "sha256:abc", + Enabled: true, CommandCollected: true, + } + src := cpInventory("source", "acct") + src.Cron.Jobs = []CronJobEntry{job} + dest := cpInventory("destination", "acct") + dest.Cron.Jobs = []CronJobEntry{job} + + p := BuildCronPlan(src, dest) + op := findCronOp(t, p, CronSectionJobs, "/usr/bin/true") + if op.Action != CronActionSkip { + t.Fatalf("identical job: action = %q, want skip", op.Action) + } +} + +func TestCronPlanDisabledJobManual(t *testing.T) { + src := cpInventory("source", "acct") + src.Cron.Jobs = []CronJobEntry{ + {Type: "schedule", Minute: "0", Hour: "0", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/false", + CommandSHA256: "sha256:def", Enabled: false, CommandCollected: true}, + } + dest := cpInventory("destination", "acct") + + p := BuildCronPlan(src, dest) + op := findCronOp(t, p, CronSectionJobs, "/usr/bin/false") + if op.Action != CronActionManual { + t.Fatalf("disabled job: action = %q, want manual", op.Action) + } + if !strings.Contains(op.Reason, "disabled") { + t.Errorf("reason %q should mention disabled", op.Reason) + } +} + +func TestCronPlanDifferentScheduleManual(t *testing.T) { + src := cpInventory("source", "acct") + src.Cron.Jobs = []CronJobEntry{ + {Type: "schedule", Minute: "0", Hour: "3", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/true", + CommandSHA256: "sha256:abc", Enabled: true, CommandCollected: true}, + } + dest := cpInventory("destination", "acct") + dest.Cron.Jobs = []CronJobEntry{ + {Type: "schedule", Minute: "30", Hour: "4", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/true", + CommandSHA256: "sha256:abc", Enabled: true, CommandCollected: true}, + } + + p := BuildCronPlan(src, dest) + op := findCronOp(t, p, CronSectionJobs, "/usr/bin/true") + if op.Action != CronActionManual { + t.Fatalf("different schedule: action = %q, want manual", op.Action) + } +} + +func TestCronPlanPathAdaptation(t *testing.T) { + src := cpInventory("source", "olduser") + src.Cron.Jobs = []CronJobEntry{ + {Type: "schedule", Minute: "0", Hour: "3", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/home/olduser/backup.sh", + CommandSHA256: "sha256:path1", + Enabled: true, CommandCollected: true}, + } + dest := cpInventory("destination", "newuser") + + p := BuildCronPlan(src, dest) + op := findCronOp(t, p, CronSectionJobs, "/home/olduser/backup.sh") + if op.Action != CronActionCreate { + t.Fatalf("action = %q, want create", op.Action) + } + if !op.PathAdapted { + t.Error("path_adapted should be true") + } + if !strings.Contains(op.Line, "/home/newuser/") { + t.Errorf("adapted line %q should contain /home/newuser/", op.Line) + } + if !strings.Contains(op.SourceLine, "/home/olduser/") { + t.Errorf("source line %q should contain /home/olduser/", op.SourceLine) + } +} + +func TestCronPlanNotCollectedManual(t *testing.T) { + src := cpInventory("source", "acct") + src.Cron.Jobs = []CronJobEntry{ + {Type: "schedule", Minute: "0", Hour: "0", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/something", CommandSHA256: "sha256:old", + Enabled: true, CommandCollected: false}, + } + dest := cpInventory("destination", "acct") + + p := BuildCronPlan(src, dest) + op := findCronOp(t, p, CronSectionJobs, "/usr/bin/something") + if op.Action != CronActionManual { + t.Fatalf("not collected: action = %q, want manual", op.Action) + } + if !strings.Contains(op.Reason, "pre-2A") { + t.Errorf("reason %q should mention pre-2A", op.Reason) + } +} + +func TestCronPlanEnvCreate(t *testing.T) { + src := cpInventory("source", "acct") + src.Cron.Environment = []CronEnvEntry{ + {Name: "MAILTO", ValueRedacted: "test@example.com", ValueCollected: true}, + } + dest := cpInventory("destination", "acct") + + p := BuildCronPlan(src, dest) + op := findCronOp(t, p, CronSectionEnv, "MAILTO") + if op.Action != CronActionCreate { + t.Fatalf("env create: action = %q, want create", op.Action) + } + if op.Line != "MAILTO=test@example.com" { + t.Errorf("env line = %q, want MAILTO=test@example.com", op.Line) + } +} + +func TestCronPlanEnvSkip(t *testing.T) { + env := CronEnvEntry{Name: "PATH", ValueRedacted: "/usr/bin", ValueCollected: true} + src := cpInventory("source", "acct") + src.Cron.Environment = []CronEnvEntry{env} + dest := cpInventory("destination", "acct") + dest.Cron.Environment = []CronEnvEntry{env} + + p := BuildCronPlan(src, dest) + op := findCronOp(t, p, CronSectionEnv, "PATH") + if op.Action != CronActionSkip { + t.Fatalf("env skip: action = %q, want skip", op.Action) + } +} + +func TestCronPlanEnvDifferentManual(t *testing.T) { + src := cpInventory("source", "acct") + src.Cron.Environment = []CronEnvEntry{ + {Name: "MAILTO", ValueRedacted: "src@x.com", ValueCollected: true}, + } + dest := cpInventory("destination", "acct") + dest.Cron.Environment = []CronEnvEntry{ + {Name: "MAILTO", ValueRedacted: "dest@x.com", ValueCollected: true}, + } + + p := BuildCronPlan(src, dest) + op := findCronOp(t, p, CronSectionEnv, "MAILTO") + if op.Action != CronActionManual { + t.Fatalf("env different: action = %q, want manual", op.Action) + } +} + +func TestCronPlanSummary(t *testing.T) { + src := cpInventory("source", "acct") + src.Cron.Jobs = []CronJobEntry{ + {Type: "schedule", Minute: "0", Hour: "0", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/create-me", + CommandSHA256: "sha256:c1", Enabled: true, CommandCollected: true}, + {Type: "schedule", Minute: "0", Hour: "0", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/usr/bin/disabled", + CommandSHA256: "sha256:d1", Enabled: false, CommandCollected: true}, + } + dest := cpInventory("destination", "acct") + + p := BuildCronPlan(src, dest) + if p.Summary.Create != 1 || p.Summary.Manual != 1 { + t.Errorf("summary = %+v, want create=1 manual=1", p.Summary) + } +} diff --git a/internal/accountinventory/cronplan_write.go b/internal/accountinventory/cronplan_write.go new file mode 100644 index 00000000..ea99f39f --- /dev/null +++ b/internal/accountinventory/cronplan_write.go @@ -0,0 +1,99 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// WriteCronPlanJSON writes the machine-readable cron apply plan. +func WriteCronPlanJSON(path string, p CronApplyPlan) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal cron plan: %w", err) + } + b = append(b, '\n') + return os.WriteFile(path, b, 0o600) +} + +// WriteCronPlanMarkdown writes the human-readable cron plan. Manual items +// are listed first: they are the ones requiring operator work before an +// apply is meaningful. +func WriteCronPlanMarkdown(path string, p CronApplyPlan) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + + fmt.Fprintf(&sb, "# Cron Apply Plan\n\n") + fmt.Fprintf(&sb, "- **Source**: %s (sha256 %s)\n", p.SourceFile, p.SourceSHA256) + fmt.Fprintf(&sb, "- **Destination**: %s (sha256 %s)\n", p.DestinationFile, p.DestinationSHA256) + fmt.Fprintf(&sb, "- **Accounts**: source `%s` -> destination `%s`\n", p.SourceUser, p.DestinationUser) + fmt.Fprintf(&sb, "- **Generated**: %s\n\n", p.GeneratedAt) + fmt.Fprintf(&sb, "**Summary**: %d create, %d skip, %d manual, %d informational\n\n", + p.Summary.Create, p.Summary.Skip, p.Summary.Manual, p.Summary.Informational) + sb.WriteString("The plan never deletes destination-only entries; `manual` items are never applied and have no override.\n\n") + + if len(p.Ops) == 0 && len(p.Informational) == 0 { + sb.WriteString("No cron items to plan.\n") + return os.WriteFile(path, []byte(sb.String()), 0o600) + } + + for _, action := range []string{CronActionManual, CronActionCreate, CronActionSkip} { + var rows []CronPlanOp + for _, op := range p.Ops { + if op.Action == action { + rows = append(rows, op) + } + } + if len(rows) == 0 { + continue + } + fmt.Fprintf(&sb, "## %s (%d)\n\n", strings.ToUpper(action), len(rows)) + sb.WriteString("| Section | Key | Detail | Note |\n|---------|-----|--------|------|\n") + for _, op := range rows { + detail := cronPlanDetail(op) + note := op.Reason + fmt.Fprintf(&sb, "| %s | %s | %s | %s |\n", + mdCell(op.Section, 20), mdCell(op.Key, 60), mdCell(detail, 90), mdCell(note, 110)) + } + sb.WriteString("\n") + } + + if len(p.Informational) > 0 { + fmt.Fprintf(&sb, "## Destination-only items (%d, never deleted)\n\n", len(p.Informational)) + sb.WriteString("| Section | Key | Value |\n|---------|-----|-------|\n") + for _, info := range p.Informational { + fmt.Fprintf(&sb, "| %s | %s | %s |\n", + mdCell(info.Section, 20), mdCell(info.Key, 60), mdCell(info.Value, 80)) + } + sb.WriteString("\n") + } + + return os.WriteFile(path, []byte(sb.String()), 0o600) +} + +// cronPlanDetail renders the detail column for a cron plan op. +func cronPlanDetail(op CronPlanOp) string { + switch op.Action { + case CronActionCreate: + if op.PathAdapted { + return fmt.Sprintf("create (path-adapted) %s", op.SourceValue) + } + return fmt.Sprintf("create %s", op.SourceValue) + case CronActionSkip: + return fmt.Sprintf("skip - already present %s", op.DestinationValue) + case CronActionManual: + return fmt.Sprintf("manual %s", op.SourceValue) + default: + return op.Action + } +} diff --git a/internal/accountinventory/diff.go b/internal/accountinventory/diff.go new file mode 100644 index 00000000..60324a13 --- /dev/null +++ b/internal/accountinventory/diff.go @@ -0,0 +1,670 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +// Deterministic comparison of two NormalizedInventory documents. The diff +// only states WHAT differs (source -> destination); it never judges whether +// a difference is safe or dangerous - that is a later, separate concern. +// +// Direction: "removed" = present only in the source, "added" = present only +// in the destination. + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" +) + +// DiffEntry is one added or removed item, identified by its section +// matching key. Detail carries an already-safe human hint (for cron it is +// the REDACTED command - the raw command does not exist in the inventory). +type DiffEntry struct { + Key string `json:"key"` + Detail string `json:"detail,omitempty"` +} + +// DiffFieldChange is one field whose value differs on an item present on +// both sides. +type DiffFieldChange struct { + Key string `json:"key"` + Field string `json:"field"` + Source string `json:"source"` + Destination string `json:"destination"` +} + +type SectionDiff struct { + Added []DiffEntry `json:"added"` + Removed []DiffEntry `json:"removed"` + Changed []DiffFieldChange `json:"changed"` + // Skipped lists comparisons that could NOT be performed (section or + // zone unavailable on either side). It is a structured signal - the + // policy engine gates on it, so it must never be folded into the + // free-text Warnings. + Skipped []string `json:"skipped"` + Warnings []string `json:"warnings"` +} + +func newSectionDiff() SectionDiff { + return SectionDiff{ + Added: []DiffEntry{}, + Removed: []DiffEntry{}, + Changed: []DiffFieldChange{}, + Skipped: []string{}, + Warnings: []string{}, + } +} + +type DiffSummary struct { + SectionsCompared int `json:"sections_compared"` + Added int `json:"added"` + Removed int `json:"removed"` + Changed int `json:"changed"` + Warnings int `json:"warnings"` +} + +type InventoryDiff struct { + Mode string `json:"mode"` + SourceFile string `json:"source_file"` + DestinationFile string `json:"destination_file"` + // SourceSHA256/DestinationSHA256 hash the raw bytes of the two input + // files (set by the CLI, same stale-input defense as the DNS plan); + // the checklist verifies the provenance chain against them (PR 7B). + // omitempty keeps artifacts produced by older builds parseable. + SourceSHA256 string `json:"source_sha256,omitempty"` + DestinationSHA256 string `json:"destination_sha256,omitempty"` + GeneratedAt string `json:"generated_at"` + Summary DiffSummary `json:"summary"` + Sections map[string]SectionDiff `json:"sections"` + // Warnings holds cross-section warnings. Currently always empty - + // per-section warnings live in each SectionDiff - but it is part of + // the diff schema consumers can already rely on. + Warnings []string `json:"warnings"` +} + +// diffSectionNames fixes the section order for reports and iteration. +var diffSectionNames = []string{ + "domains", "mailboxes", "databases", "forwarders", "autoresponders", + "ftp", "ssl", "php", "dns", "cron", + "email_routing", "default_address", "email_filters", "redirects", +} + +// DiffInventories compares two inventories section by section. It is pure +// and deterministic: file names and timestamp are the caller's concern. +func DiffInventories(src, dest NormalizedInventory) InventoryDiff { + d := InventoryDiff{ + Mode: "inventory-diff", + Sections: map[string]SectionDiff{}, + Warnings: []string{}, + } + + d.Sections["domains"] = diffKeyed(domainItems(src.Domains), domainItems(dest.Domains)) + d.Sections["mailboxes"] = diffKeyed(mailboxItems(src.Mailboxes), mailboxItems(dest.Mailboxes)) + d.Sections["databases"] = diffKeyed(databaseItems(src.Databases), databaseItems(dest.Databases)) + d.Sections["forwarders"] = diffKeyed(forwarderItems(src.Forwarders), forwarderItems(dest.Forwarders)) + d.Sections["autoresponders"] = diffKeyed(autoresponderItems(src.Autoresponders), autoresponderItems(dest.Autoresponders)) + d.Sections["ftp"] = diffConfigKeyed("ftp", src.FTP.ConfigSection, dest.FTP.ConfigSection, + ftpItems(src.FTP.Items), ftpItems(dest.FTP.Items)) + d.Sections["ssl"] = diffConfigKeyed("ssl", src.SSL.ConfigSection, dest.SSL.ConfigSection, + sslItems(src.SSL.Items), sslItems(dest.SSL.Items)) + d.Sections["php"] = diffConfigKeyed("php", src.PHP.ConfigSection, dest.PHP.ConfigSection, + phpItems(src.PHP.Items), phpItems(dest.PHP.Items)) + d.Sections["dns"] = diffDNS(src.DNS, dest.DNS) + d.Sections["cron"] = diffCron(src.Cron, dest.Cron) + d.Sections["email_routing"] = diffConfigKeyed("email_routing", + src.EmailRouting.ConfigSection, dest.EmailRouting.ConfigSection, + emailRoutingItems(src.EmailRouting.Items), emailRoutingItems(dest.EmailRouting.Items)) + d.Sections["default_address"] = diffConfigKeyed("default_address", + src.DefaultAddresses.ConfigSection, dest.DefaultAddresses.ConfigSection, + defaultAddressItems(src.DefaultAddresses.Items), defaultAddressItems(dest.DefaultAddresses.Items)) + d.Sections["email_filters"] = diffConfigKeyed("email_filters", + src.EmailFilters.ConfigSection, dest.EmailFilters.ConfigSection, + emailFilterItems(src.EmailFilters.Items), emailFilterItems(dest.EmailFilters.Items)) + d.Sections["redirects"] = diffConfigKeyed("redirects", + src.Redirects.ConfigSection, dest.Redirects.ConfigSection, + redirectItems(src.Redirects.Items), redirectItems(dest.Redirects.Items)) + + d.Summary.SectionsCompared = len(d.Sections) + for _, sec := range d.Sections { + d.Summary.Added += len(sec.Added) + d.Summary.Removed += len(sec.Removed) + d.Summary.Changed += len(sec.Changed) + d.Summary.Warnings += len(sec.Warnings) + len(sec.Skipped) + } + d.Summary.Warnings += len(d.Warnings) + return d +} + +// --------------------------------------------------------------------------- +// Generic keyed comparison +// --------------------------------------------------------------------------- + +// keyedItem is one comparable item: an identity key, an optional +// human-safe detail, and the named fields compared for "changed". +type keyedItem struct { + key string + detail string + fields map[string]string +} + +func diffKeyed(srcItems, destItems []keyedItem) SectionDiff { + sec := newSectionDiff() + + srcMap := map[string]keyedItem{} + for _, it := range srcItems { + if _, dup := srcMap[it.key]; dup { + sec.Warnings = append(sec.Warnings, fmt.Sprintf("duplicate key %q in source - last occurrence wins", it.key)) + } + srcMap[it.key] = it + } + destMap := map[string]keyedItem{} + for _, it := range destItems { + if _, dup := destMap[it.key]; dup { + sec.Warnings = append(sec.Warnings, fmt.Sprintf("duplicate key %q in destination - last occurrence wins", it.key)) + } + destMap[it.key] = it + } + + for key, it := range destMap { + if _, ok := srcMap[key]; !ok { + sec.Added = append(sec.Added, DiffEntry{Key: key, Detail: it.detail}) + } + } + for key, it := range srcMap { + if _, ok := destMap[key]; !ok { + sec.Removed = append(sec.Removed, DiffEntry{Key: key, Detail: it.detail}) + continue + } + other := destMap[key] + fields := make([]string, 0, len(it.fields)) + for f := range it.fields { + fields = append(fields, f) + } + sort.Strings(fields) + for _, f := range fields { + if it.fields[f] != other.fields[f] { + sec.Changed = append(sec.Changed, DiffFieldChange{ + Key: key, Field: f, Source: it.fields[f], Destination: other.fields[f], + }) + } + } + } + + sortSectionDiff(&sec) + return sec +} + +// diffConfigKeyed wraps diffKeyed for ConfigSection-based sections: when +// either side is unavailable the comparison is skipped with a warning - +// an unavailable section is NOT an empty one, and its absent items must +// not read as removals. +func diffConfigKeyed(name string, srcCfg, destCfg ConfigSection, srcItems, destItems []keyedItem) SectionDiff { + if sec, skipped := skipUnavailable(name, srcCfg.Available, destCfg.Available); skipped { + return sec + } + return diffKeyed(srcItems, destItems) +} + +func skipUnavailable(name string, srcAvail, destAvail bool) (SectionDiff, bool) { + sec := newSectionDiff() + if !srcAvail || !destAvail { + side := "source" + if srcAvail { + side = "destination" + } else if !destAvail { + side = "source and destination" + } + sec.Skipped = append(sec.Skipped, + fmt.Sprintf("%s unavailable on %s - comparison skipped", name, side)) + return sec, true + } + return sec, false +} + +func sortSectionDiff(sec *SectionDiff) { + // Detail is the secondary key: entries can legitimately share a Key + // (e.g. one cron command scheduled several times), and without a full + // order the map-iteration order of their producer would leak into the + // output, breaking the determinism contract. + byKeyDetail := func(entries []DiffEntry) func(i, j int) bool { + return func(i, j int) bool { + if entries[i].Key != entries[j].Key { + return entries[i].Key < entries[j].Key + } + return entries[i].Detail < entries[j].Detail + } + } + sort.Slice(sec.Added, byKeyDetail(sec.Added)) + sort.Slice(sec.Removed, byKeyDetail(sec.Removed)) + sort.Slice(sec.Changed, func(i, j int) bool { + if sec.Changed[i].Key != sec.Changed[j].Key { + return sec.Changed[i].Key < sec.Changed[j].Key + } + return sec.Changed[i].Field < sec.Changed[j].Field + }) + sort.Strings(sec.Skipped) + sort.Strings(sec.Warnings) +} + +// --------------------------------------------------------------------------- +// Per-section item adapters +// --------------------------------------------------------------------------- + +func domainItems(in []DomainEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + out = append(out, keyedItem{ + key: e.Name, + detail: e.Type, + fields: map[string]string{"type": e.Type, "document_root": e.DocumentRoot}, + }) + } + return out +} + +// mailboxItems compares existence only: disk usage is volatile noise for a +// migration diff. +func mailboxItems(in []MailboxEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + out = append(out, keyedItem{key: e.Email}) + } + return out +} + +func databaseItems(in []DatabaseEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + users := append([]string(nil), e.Users...) + sort.Strings(users) + out = append(out, keyedItem{ + key: e.Name, + fields: map[string]string{"users": strings.Join(users, ",")}, + }) + } + return out +} + +// forwarderItems: the key IS the whole content, so any change is +// added+removed. +func forwarderItems(in []NormForwarderEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + out = append(out, keyedItem{key: e.Domain + " | " + e.Source + " -> " + e.Destination}) + } + return out +} + +func autoresponderItems(in []NormAutoresponderEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + // Collected bodies compare under the same trailing-newline storage + // normalization the plan uses (2B-2-pre fact 5): a difference the + // plan treats as skip-equivalent must not surface as changed here. + body := e.Body + if e.BodyCollected { + body = normalizeAutoresponderBody(body) + } + out = append(out, keyedItem{ + key: e.Domain + " | " + e.Email, + fields: map[string]string{ + "subject": e.Subject, + "interval": strconv.Itoa(e.Interval), + // PR 2B-2 content fields: a different body/from/is_html/ + // start/stop is a real behavioral difference. The + // body_collected marker itself is compared so a + // collected-vs-not asymmetry (pre-2B-2 artifact on one + // side) is visible instead of silently equal. + "from": e.From, + "body": body, + "is_html": strconv.Itoa(e.IsHTML), + "start": strconv.FormatInt(e.Start, 10), + "stop": strconv.FormatInt(e.Stop, 10), + "charset": autoresponderCharset(e.Charset), + "body_collected": strconv.FormatBool(e.BodyCollected), + }, + }) + } + return out +} + +func ftpItems(in []FTPEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + out = append(out, keyedItem{ + key: e.Login, + fields: map[string]string{"type": e.Type, "dir": e.Dir}, + }) + } + return out +} + +func sslItems(in []SSLEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + out = append(out, keyedItem{ + key: e.Domains, + detail: e.Issuer, + fields: map[string]string{ + "issuer": e.Issuer, + "validation_type": e.ValidationType, + "is_self_signed": strconv.FormatBool(e.IsSelfSigned), + "valid_until": strconv.FormatInt(e.ValidUntil, 10), + }, + }) + } + return out +} + +func phpItems(in []PHPEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + out = append(out, keyedItem{ + key: e.Domain, + detail: e.Version, + fields: map[string]string{"version": e.Version}, + }) + } + return out +} + +// --------------------------------------------------------------------------- +// DNS: zone-aware, order-insensitive comparison +// --------------------------------------------------------------------------- + +// dnsValue carries the two renderings of one record's comparable value: +// compare is unambiguous (NUL-framed fields - free-text TXT values that +// happen to contain "prio=..."/"ttl=..." cannot collide with a genuinely +// prioritized record), display is the human form used in reports. +type dnsValue struct { + compare string + display string +} + +func canonicalDNSValue(r cpanel.DNSRecord) dnsValue { + display := r.Value + if r.Priority != 0 { + display = fmt.Sprintf("prio=%d %s", r.Priority, display) + } + display = fmt.Sprintf("%s ttl=%d", display, r.TTL) + return dnsValue{ + compare: fmt.Sprintf("%d\x00%d\x00%s", r.Priority, r.TTL, r.Value), + display: display, + } +} + +func diffDNS(src, dest DNSSection) SectionDiff { + if sec, skipped := skipUnavailable("dns", src.Available, dest.Available); skipped { + return sec + } + sec := newSectionDiff() + + srcZones := map[string]DNSZoneResult{} + for _, z := range src.Zones { + srcZones[z.Zone] = z + } + destZones := map[string]DNSZoneResult{} + for _, z := range dest.Zones { + destZones[z.Zone] = z + } + + for name, z := range destZones { + if _, ok := srcZones[name]; !ok { + sec.Added = append(sec.Added, DiffEntry{ + Key: "zone " + name, Detail: fmt.Sprintf("%d record(s)", len(z.Records)), + }) + } + } + for name, sz := range srcZones { + dz, ok := destZones[name] + if !ok { + sec.Removed = append(sec.Removed, DiffEntry{ + Key: "zone " + name, Detail: fmt.Sprintf("%d record(s)", len(sz.Records)), + }) + continue + } + if !sz.Available || !dz.Available { + sec.Skipped = append(sec.Skipped, + fmt.Sprintf("zone %s unavailable on one side - records not compared", name)) + continue + } + diffZoneRecords(&sec, name, sz.Records, dz.Records) + } + + sortSectionDiff(&sec) + return sec +} + +// diffZoneRecords groups records by (type, name) and compares the sorted +// canonical value-sets: same group with different values is a "changed" +// entry, a group present on one side only is added/removed. Equality is +// decided on the unambiguous compare form; reports show the display form. +func diffZoneRecords(sec *SectionDiff, zone string, src, dest []cpanel.DNSRecord) { + group := func(records []cpanel.DNSRecord) map[string][]dnsValue { + g := map[string][]dnsValue{} + for _, r := range records { + k := r.Type + " " + r.Name + g[k] = append(g[k], canonicalDNSValue(r)) + } + for k := range g { + sort.Slice(g[k], func(i, j int) bool { return g[k][i].compare < g[k][j].compare }) + } + return g + } + compareSet := func(values []dnsValue) string { + parts := make([]string, 0, len(values)) + for _, v := range values { + parts = append(parts, v.compare) + } + return strings.Join(parts, "\x01") + } + displaySet := func(values []dnsValue) string { + parts := make([]string, 0, len(values)) + for _, v := range values { + parts = append(parts, v.display) + } + return strings.Join(parts, "; ") + } + sg, dg := group(src), group(dest) + + for k, values := range dg { + if _, ok := sg[k]; !ok { + sec.Added = append(sec.Added, DiffEntry{ + Key: "zone " + zone + " " + k, Detail: displaySet(values), + }) + } + } + for k, srcValues := range sg { + destValues, ok := dg[k] + if !ok { + sec.Removed = append(sec.Removed, DiffEntry{ + Key: "zone " + zone + " " + k, Detail: displaySet(srcValues), + }) + continue + } + if compareSet(srcValues) != compareSet(destValues) { + sec.Changed = append(sec.Changed, DiffFieldChange{ + Key: "zone " + zone + " " + k, + Field: "records", + Source: displaySet(srcValues), + Destination: displaySet(destValues), + }) + } + } +} + +// --------------------------------------------------------------------------- +// Cron: compared by command_sha256, never by (nonexistent) raw command +// --------------------------------------------------------------------------- + +func cronSchedule(j CronJobEntry) string { + if j.Type == "macro" { + return j.Macro + } + return strings.Join([]string{j.Minute, j.Hour, j.DayOfMonth, j.Month, j.DayOfWeek}, " ") +} + +func diffCron(src, dest CronSection) SectionDiff { + if sec, skipped := skipUnavailable("cron", src.Available, dest.Available); skipped { + return sec + } + sec := newSectionDiff() + + group := func(jobs []CronJobEntry) map[string][]CronJobEntry { + g := map[string][]CronJobEntry{} + for _, j := range jobs { + g[j.CommandSHA256] = append(g[j.CommandSHA256], j) + } + return g + } + sg, dg := group(src.Jobs), group(dest.Jobs) + + // The command hash is computed over the redacted command, so hash and + // redacted text identify each other 1:1 - the readable redacted form + // is used as the diff key. + keyOf := func(jobs []CronJobEntry) string { return jobs[0].CommandRedacted } + + // Detail always carries the enabled flag: policy rules must be able + // to tell a lost ACTIVE job from a lost disabled one. + slotDetail := func(j CronJobEntry) string { + return cronSchedule(j) + " enabled=" + strconv.FormatBool(j.Enabled) + } + for sha, jobs := range dg { + if _, ok := sg[sha]; !ok { + for _, j := range jobs { + sec.Added = append(sec.Added, DiffEntry{Key: keyOf(jobs), Detail: slotDetail(j)}) + } + } + } + for sha, srcJobs := range sg { + destJobs, ok := dg[sha] + if !ok { + for _, j := range srcJobs { + sec.Removed = append(sec.Removed, DiffEntry{Key: keyOf(srcJobs), Detail: slotDetail(j)}) + } + continue + } + key := keyOf(srcJobs) + if len(srcJobs) == 1 && len(destJobs) == 1 { + if s, d := cronSchedule(srcJobs[0]), cronSchedule(destJobs[0]); s != d { + sec.Changed = append(sec.Changed, DiffFieldChange{ + Key: key, Field: "schedule", Source: s, Destination: d, + }) + } + if srcJobs[0].Enabled != destJobs[0].Enabled { + sec.Changed = append(sec.Changed, DiffFieldChange{ + Key: key, Field: "enabled", + Source: strconv.FormatBool(srcJobs[0].Enabled), + Destination: strconv.FormatBool(destJobs[0].Enabled), + }) + } + continue + } + // Same command scheduled multiple times: compare the multiset of + // schedule|enabled slots. + srcSlots := map[string]int{} + for _, j := range srcJobs { + srcSlots[slotDetail(j)]++ + } + destSlots := map[string]int{} + for _, j := range destJobs { + destSlots[slotDetail(j)]++ + } + for s, n := range destSlots { + for i := srcSlots[s]; i < n; i++ { + sec.Added = append(sec.Added, DiffEntry{Key: key, Detail: s}) + } + } + for s, n := range srcSlots { + for i := destSlots[s]; i < n; i++ { + sec.Removed = append(sec.Removed, DiffEntry{Key: key, Detail: s}) + } + } + } + + sortSectionDiff(&sec) + return sec +} + +// --- PR 7E adapters -------------------------------------------------------- + +// emailRoutingItems compares only the operator-set facts (routing mode +// and always_accept). `detected` is cPanel's runtime detection and the +// MX rrsets are already diffed by the dns section - comparing them here +// would double-report; they stay visible in the human detail. +func emailRoutingItems(in []EmailRoutingEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + mx := make([]string, 0, len(e.MXRecords)) + for _, m := range e.MXRecords { + mx = append(mx, fmt.Sprintf("%d %s", m.Priority, m.Exchange)) + } + out = append(out, keyedItem{ + key: e.Domain, + detail: fmt.Sprintf("detected=%s; mx=%s", e.Detected, strings.Join(mx, ", ")), + fields: map[string]string{ + "routing": e.Routing, + "always_accept": strconv.FormatBool(e.AlwaysAccept), + }, + }) + } + return out +} + +func defaultAddressItems(in []DefaultAddressEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + out = append(out, keyedItem{ + key: e.Domain, + fields: map[string]string{"default_address": e.DefaultAddress}, + }) + } + return out +} + +func emailFilterItems(in []NormEmailFilterEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + account := e.Account + if account == "" { + account = "(account-level)" + } + out = append(out, keyedItem{ + key: account + "/" + e.FilterName, + fields: map[string]string{ + "enabled": strconv.FormatBool(e.Enabled), + "rule_count": strconv.Itoa(e.RuleCount), + "action_count": strconv.Itoa(e.ActionCount), + }, + }) + } + return out +} + +// redirectItems encodes the classification facts in the detail +// (`kind/type/status -> destination`): the policy layer recognizes +// CMS-generated .htaccess rewrites by the `rewrite/temporary/-` prefix, +// the same detail-string channel evalDomains uses for `main`. +func redirectItems(in []RedirectEntry) []keyedItem { + out := make([]keyedItem, 0, len(in)) + for _, e := range in { + status := "-" + if e.StatusCode != 0 { + status = strconv.FormatInt(e.StatusCode, 10) + } + out = append(out, keyedItem{ + key: e.Domain + " " + e.Source, + detail: fmt.Sprintf("%s/%s/%s -> %s", e.Kind, e.Type, status, e.Destination), + fields: map[string]string{ + "destination": e.Destination, + "kind": e.Kind, + "type": e.Type, + "status_code": status, + }, + }) + } + return out +} diff --git a/internal/accountinventory/diff_test.go b/internal/accountinventory/diff_test.go new file mode 100644 index 00000000..9129eadb --- /dev/null +++ b/internal/accountinventory/diff_test.go @@ -0,0 +1,578 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "strings" + "testing" +) + +// baseInventory returns a fully-populated inventory used as the diff +// baseline; tests mutate copies of it. +func baseInventory() NormalizedInventory { + inv := NewEmptyInventory("u", "192.0.2.1", "source") + inv.Domains = []DomainEntry{ + {Name: "main.example", Type: "main", DocumentRoot: "/home/u/public_html"}, + {Name: "addon.example", Type: "addon", DocumentRoot: "/home/u/addon.example"}, + } + inv.Mailboxes = []MailboxEntry{ + {Email: "info@main.example", Domain: "main.example", User: "info", DiskUsage: 1024}, + } + inv.Databases = []DatabaseEntry{ + {Name: "u_wp", DiskUsage: 2048, Users: []string{"u_admin"}}, + } + inv.Forwarders = []NormForwarderEntry{ + {Source: "info@main.example", Destination: "admin@gmail.com", Domain: "main.example"}, + } + inv.Autoresponders = []NormAutoresponderEntry{ + {Email: "info@main.example", Domain: "main.example", Subject: "OOO", Interval: 24}, + } + inv.FTP = FTPSection{ + ConfigSection: ConfigSection{Available: true, Method: "uapi", SourceFunction: "Ftp::list_ftp_with_disk", Warnings: []string{}}, + Items: []FTPEntry{{Login: "up@main.example", Type: "sub", Dir: "/home/u/up", DiskUsed: 5}}, + } + inv.SSL = SSLSection{ + ConfigSection: ConfigSection{Available: true, Method: "uapi", SourceFunction: "SSL::list_certs", Warnings: []string{}}, + Items: []SSLEntry{{ + Domains: "main.example,www.main.example", Issuer: "R3", + ValidFrom: 1700000000, ValidUntil: 1724976000, ValidationType: "dv", + }}, + } + inv.PHP = PHPSection{ + ConfigSection: ConfigSection{Available: true, Method: "uapi", SourceFunction: "LangPHP::php_get_vhost_versions", Warnings: []string{}}, + Items: []PHPEntry{{Domain: "main.example", Version: "ea-php81"}}, + } + inv.DNS = DNSSection{ + ConfigSection: ConfigSection{Available: true, Method: "uapi", SourceFunction: "DNS::parse_zone", Warnings: []string{}}, + Zones: []DNSZoneResult{{ + Available: true, Zone: "main.example", Method: "uapi", SourceFunction: "DNS::parse_zone", + Records: []cpanel.DNSRecord{ + {Type: "A", Name: "main.example.", TTL: 14400, Value: "192.0.2.1", Address: "192.0.2.1"}, + {Type: "MX", Name: "main.example.", TTL: 14400, Value: "mail.main.example.", Exchange: "mail.main.example.", Priority: 10}, + {Type: "TXT", Name: "main.example.", TTL: 14400, Value: "v=spf1 ~all", TxtData: "v=spf1 ~all"}, + }, + Warnings: []string{}, Errors: []string{}, + }}, + } + inv.Cron = CronSection{ + Available: true, Method: "ssh_crontab_l", SourceCommand: "crontab -l", + Jobs: []CronJobEntry{{ + Type: "schedule", Minute: "0", Hour: "3", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/bin/backup --password=[REDACTED]", + CommandSHA256: "sha256:aabb", RawLineSHA256: "sha256:ccdd", + Enabled: true, LineNumber: 2, Warnings: []string{}, + }}, + Environment: []CronEnvEntry{}, Warnings: []string{}, Errors: []string{}, + } + inv.EmailRouting = EmailRoutingSection{ + ConfigSection: ConfigSection{Available: true, Method: "uapi", SourceFunction: "Email::list_mxs", Warnings: []string{}}, + Items: []EmailRoutingEntry{{ + Domain: "main.example", Routing: "local", Detected: "local", AlwaysAccept: true, + MXRecords: []MXRecordEntry{{Priority: 0, Exchange: "main.example"}}, + }}, + } + inv.DefaultAddresses = DefaultAddressSection{ + ConfigSection: ConfigSection{Available: true, Method: "uapi", SourceFunction: "Email::list_default_address", Warnings: []string{}}, + Items: []DefaultAddressEntry{{ + Domain: "main.example", DefaultAddress: `":fail: No Such User Here"`, + }}, + } + inv.EmailFilters = EmailFilterSection{ + ConfigSection: ConfigSection{Available: true, Method: "uapi", SourceFunction: "Email::list_filters", Warnings: []string{}}, + Items: []NormEmailFilterEntry{{ + Account: "", FilterName: "spam-to-junk", Enabled: true, RuleCount: 1, ActionCount: 1, + }}, + } + inv.Redirects = RedirectSection{ + ConfigSection: ConfigSection{Available: true, Method: "uapi", SourceFunction: "Mime::list_redirects", Warnings: []string{}}, + Items: []RedirectEntry{{ + Domain: "addon.example", Source: "/", Destination: "https://main.example/", + Kind: "rewrite", Type: "permanent", StatusCode: 301, Wildcard: true, MatchWWW: true, + }}, + } + return inv +} + +func sectionOf(t *testing.T, d InventoryDiff, name string) SectionDiff { + t.Helper() + sec, ok := d.Sections[name] + if !ok { + t.Fatalf("section %q missing from diff", name) + } + return sec +} + +func assertClean(t *testing.T, d InventoryDiff) { + t.Helper() + if d.Summary.Added != 0 || d.Summary.Removed != 0 || d.Summary.Changed != 0 { + t.Errorf("expected no differences, summary = %+v", d.Summary) + } +} + +// --------------------------------------------------------------------------- +// Core engine +// --------------------------------------------------------------------------- + +func TestDiffIdenticalInventories(t *testing.T) { + d := DiffInventories(baseInventory(), baseInventory()) + if d.Mode != "inventory-diff" { + t.Errorf("mode = %q", d.Mode) + } + if d.Summary.SectionsCompared != 14 { + t.Errorf("sections compared = %d, want 14", d.Summary.SectionsCompared) + } + assertClean(t, d) +} + +func TestDiffOrderDoesNotMatter(t *testing.T) { + src := baseInventory() + dest := baseInventory() + // Reverse every orderable list on one side. + dest.Domains = []DomainEntry{src.Domains[1], src.Domains[0]} + dest.DNS.Zones[0].Records = []cpanel.DNSRecord{ + src.DNS.Zones[0].Records[2], src.DNS.Zones[0].Records[0], src.DNS.Zones[0].Records[1], + } + assertClean(t, DiffInventories(src, dest)) +} + +func TestDiffDomainAddedRemoved(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.Domains = append(dest.Domains, DomainEntry{Name: "new.example", Type: "addon"}) + src.Domains = append(src.Domains, DomainEntry{Name: "old.example", Type: "addon"}) + + d := DiffInventories(src, dest) + sec := sectionOf(t, d, "domains") + if len(sec.Added) != 1 || sec.Added[0].Key != "new.example" { + t.Errorf("added = %+v, want new.example", sec.Added) + } + if len(sec.Removed) != 1 || sec.Removed[0].Key != "old.example" { + t.Errorf("removed = %+v, want old.example", sec.Removed) + } + if d.Summary.Added != 1 || d.Summary.Removed != 1 { + t.Errorf("summary = %+v", d.Summary) + } +} + +func TestDiffDomainChangedFields(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.Domains[0].DocumentRoot = "/home/other/public_html" + + sec := sectionOf(t, DiffInventories(src, dest), "domains") + if len(sec.Changed) != 1 { + t.Fatalf("changed = %+v, want 1 entry", sec.Changed) + } + c := sec.Changed[0] + if c.Key != "main.example" || c.Field != "document_root" { + t.Errorf("changed = %+v", c) + } + if c.Source != "/home/u/public_html" || c.Destination != "/home/other/public_html" { + t.Errorf("changed values = %+v", c) + } +} + +func TestDiffMailboxExistenceOnly(t *testing.T) { + src := baseInventory() + dest := baseInventory() + // Disk usage differs: volatile, must NOT be a change. + dest.Mailboxes[0].DiskUsage = 999999 + assertClean(t, DiffInventories(src, dest)) + + dest.Mailboxes = append(dest.Mailboxes, MailboxEntry{Email: "new@main.example", Domain: "main.example", User: "new"}) + sec := sectionOf(t, DiffInventories(src, dest), "mailboxes") + if len(sec.Added) != 1 || sec.Added[0].Key != "new@main.example" { + t.Errorf("added = %+v", sec.Added) + } +} + +func TestDiffDatabaseUsersChanged(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.Databases[0].Users = []string{"u_admin", "u_extra"} + + sec := sectionOf(t, DiffInventories(src, dest), "databases") + if len(sec.Changed) != 1 || sec.Changed[0].Field != "users" { + t.Fatalf("changed = %+v", sec.Changed) + } + // Disk usage is volatile and must not appear. + src2 := baseInventory() + dest2 := baseInventory() + dest2.Databases[0].DiskUsage = 12345678 + assertClean(t, DiffInventories(src2, dest2)) +} + +func TestDiffForwarderKeyIsContent(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.Forwarders[0].Destination = "other@gmail.com" + + sec := sectionOf(t, DiffInventories(src, dest), "forwarders") + // A different destination is a different forwarder: one removed, one added. + if len(sec.Added) != 1 || len(sec.Removed) != 1 { + t.Errorf("added=%d removed=%d, want 1/1", len(sec.Added), len(sec.Removed)) + } +} + +func TestDiffPHPVersionChanged(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.PHP.Items[0].Version = "ea-php83" + + sec := sectionOf(t, DiffInventories(src, dest), "php") + if len(sec.Changed) != 1 || sec.Changed[0].Field != "version" || + sec.Changed[0].Source != "ea-php81" || sec.Changed[0].Destination != "ea-php83" { + t.Errorf("changed = %+v", sec.Changed) + } +} + +func TestDiffSSLValidUntilChanged(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.SSL.Items[0].ValidUntil = 1756512000 + + sec := sectionOf(t, DiffInventories(src, dest), "ssl") + if len(sec.Changed) != 1 || sec.Changed[0].Field != "valid_until" { + t.Errorf("changed = %+v", sec.Changed) + } +} + +func TestDiffUnavailableSectionWarnsNotPanics(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.FTP = FTPSection{ + ConfigSection: ConfigSection{Available: false, Method: "uapi", Warnings: []string{"boom"}}, + Items: []FTPEntry{}, + } + + d := DiffInventories(src, dest) + sec := sectionOf(t, d, "ftp") + // A skipped comparison is a STRUCTURED signal, not prose: the policy + // engine gates on this field, so wording changes cannot silently + // downgrade "incomplete data" to "ready". + if len(sec.Skipped) == 0 { + t.Error("unavailable ftp must populate skipped") + } + // The missing items must NOT read as removals. + if len(sec.Removed) != 0 { + t.Errorf("removed = %+v, want none (section skipped)", sec.Removed) + } + if d.Summary.Warnings == 0 { + t.Error("summary must count skipped comparisons") + } +} + +// --------------------------------------------------------------------------- +// DNS +// --------------------------------------------------------------------------- + +func TestDiffDNSRecordOrderIgnored(t *testing.T) { + src := baseInventory() + dest := baseInventory() + recs := dest.DNS.Zones[0].Records + dest.DNS.Zones[0].Records = []cpanel.DNSRecord{recs[2], recs[1], recs[0]} + assertClean(t, DiffInventories(src, dest)) +} + +func TestDiffDNSValueChanged(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.DNS.Zones[0].Records[1].Value = "mail2.main.example." + dest.DNS.Zones[0].Records[1].Exchange = "mail2.main.example." + + sec := sectionOf(t, DiffInventories(src, dest), "dns") + if len(sec.Changed) != 1 { + t.Fatalf("changed = %+v, want 1", sec.Changed) + } + c := sec.Changed[0] + if !strings.Contains(c.Key, "main.example") || !strings.Contains(c.Key, "MX") { + t.Errorf("changed key = %q, want zone+type context", c.Key) + } + if !strings.Contains(c.Source, "mail.main.example.") || !strings.Contains(c.Destination, "mail2.main.example.") { + t.Errorf("changed = %+v", c) + } +} + +func TestDiffDNSRecordAddedRemoved(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.DNS.Zones[0].Records = append(dest.DNS.Zones[0].Records, + cpanel.DNSRecord{Type: "AAAA", Name: "main.example.", TTL: 14400, Value: "2001:db8::1", Address: "2001:db8::1"}) + + sec := sectionOf(t, DiffInventories(src, dest), "dns") + if len(sec.Added) != 1 || !strings.Contains(sec.Added[0].Key, "AAAA") { + t.Errorf("added = %+v", sec.Added) + } +} + +func TestDiffDNSZoneAddedRemoved(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.DNS.Zones = append(dest.DNS.Zones, DNSZoneResult{ + Available: true, Zone: "extra.example", Method: "uapi", SourceFunction: "DNS::parse_zone", + Records: []cpanel.DNSRecord{{Type: "A", Name: "extra.example.", TTL: 300, Value: "192.0.2.9"}}, + Warnings: []string{}, Errors: []string{}, + }) + + sec := sectionOf(t, DiffInventories(src, dest), "dns") + found := false + for _, a := range sec.Added { + if strings.Contains(a.Key, "extra.example") { + found = true + } + } + if !found { + t.Errorf("zone extra.example not reported as added: %+v", sec.Added) + } +} + +func TestDiffDNSUnavailableZoneWarns(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.DNS.Zones[0] = DNSZoneResult{ + Available: false, Zone: "main.example", Method: "unavailable", + Records: []cpanel.DNSRecord{}, Warnings: []string{"zone fetch failed"}, Errors: []string{}, + } + + d := DiffInventories(src, dest) + sec := sectionOf(t, d, "dns") + if len(sec.Skipped) == 0 { + t.Error("unavailable zone must populate skipped") + } + // Records of the skipped zone must NOT read as removed. + if len(sec.Removed) != 0 { + t.Errorf("removed = %+v, want none", sec.Removed) + } +} + +// --------------------------------------------------------------------------- +// Cron +// --------------------------------------------------------------------------- + +func TestDiffCronSameHashSameSchedule(t *testing.T) { + assertClean(t, DiffInventories(baseInventory(), baseInventory())) +} + +func TestDiffCronScheduleChanged(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.Cron.Jobs[0].Hour = "5" + + sec := sectionOf(t, DiffInventories(src, dest), "cron") + if len(sec.Changed) != 1 || sec.Changed[0].Field != "schedule" { + t.Fatalf("changed = %+v", sec.Changed) + } + if !strings.Contains(sec.Changed[0].Source, "0 3") || !strings.Contains(sec.Changed[0].Destination, "0 5") { + t.Errorf("changed = %+v", sec.Changed[0]) + } +} + +func TestDiffCronEnabledChanged(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.Cron.Jobs[0].Enabled = false + + sec := sectionOf(t, DiffInventories(src, dest), "cron") + if len(sec.Changed) != 1 || sec.Changed[0].Field != "enabled" { + t.Errorf("changed = %+v", sec.Changed) + } +} + +func TestDiffCronDifferentCommand(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.Cron.Jobs[0].CommandSHA256 = "sha256:eeff" + dest.Cron.Jobs[0].CommandRedacted = "/bin/other --token=[REDACTED]" + + sec := sectionOf(t, DiffInventories(src, dest), "cron") + if len(sec.Added) != 1 || len(sec.Removed) != 1 { + t.Fatalf("added=%d removed=%d, want 1/1", len(sec.Added), len(sec.Removed)) + } + // The entry key is the REDACTED command (hash and redacted text are + // 1:1 since the hash is computed over the redacted form) - never raw. + if !strings.Contains(sec.Added[0].Key, "[REDACTED]") { + t.Errorf("added key = %q, want redacted command", sec.Added[0].Key) + } + // Detail must carry the enabled flag: the policy engine needs it to + // distinguish a lost ACTIVE job (blocker) from a lost disabled one. + for _, e := range append(append([]DiffEntry{}, sec.Added...), sec.Removed...) { + if !strings.Contains(e.Detail, "enabled=") { + t.Errorf("cron entry detail %q missing enabled flag", e.Detail) + } + } +} + +func TestDiffCronDuplicateHashDeterministic(t *testing.T) { + // Same command scheduled twice: grouped by hash, compared as a + // multiset of schedules. + src := baseInventory() + second := src.Cron.Jobs[0] + second.Hour = "15" + second.LineNumber = 3 + src.Cron.Jobs = append(src.Cron.Jobs, second) + + dest := baseInventory() + destSecond := dest.Cron.Jobs[0] + destSecond.Hour = "15" + destSecond.LineNumber = 3 + dest.Cron.Jobs = append(dest.Cron.Jobs, destSecond) + + assertClean(t, DiffInventories(src, dest)) + + // Drop one of the two on the destination -> exactly one removal. + dest.Cron.Jobs = dest.Cron.Jobs[:1] + sec := sectionOf(t, DiffInventories(src, dest), "cron") + if len(sec.Removed) != 1 { + t.Errorf("removed = %+v, want exactly 1", sec.Removed) + } +} + +func TestDiffCronDuplicateSlotOrderDeterministic(t *testing.T) { + // Same command with several schedules added on the destination: the + // added entries share the same Key and differ only in Detail. Their + // order must be deterministic (map iteration must not leak through). + build := func() (NormalizedInventory, NormalizedInventory) { + src := baseInventory() + dest := baseInventory() + for _, hour := range []string{"15", "9", "21", "4"} { + j := dest.Cron.Jobs[0] + j.Hour = hour + dest.Cron.Jobs = append(dest.Cron.Jobs, j) + } + return src, dest + } + src, dest := build() + first := DiffInventories(src, dest) + firstSec := sectionOf(t, first, "cron") + if len(firstSec.Added) != 4 { + t.Fatalf("added = %d, want 4", len(firstSec.Added)) + } + for i := 1; i < len(firstSec.Added); i++ { + prev, cur := firstSec.Added[i-1], firstSec.Added[i] + if prev.Key > cur.Key || (prev.Key == cur.Key && prev.Detail > cur.Detail) { + t.Fatalf("added not sorted by (key, detail): %+v", firstSec.Added) + } + } + // Repeat: identical input must yield an identical diff every time. + for run := 0; run < 20; run++ { + s2, d2 := build() + sec := sectionOf(t, DiffInventories(s2, d2), "cron") + for i := range firstSec.Added { + if sec.Added[i] != firstSec.Added[i] { + t.Fatalf("run %d: non-deterministic order at %d: %+v vs %+v", + run, i, sec.Added[i], firstSec.Added[i]) + } + } + } +} + +func TestDiffDNSCanonicalValueNoCollision(t *testing.T) { + // A TXT-style value that happens to START with "prio=5 " must not + // compare equal to a genuinely prioritized record with the remaining + // text: naive string concatenation would collide and silently hide + // real drift. + src := baseInventory() + dest := baseInventory() + src.DNS.Zones[0].Records = []cpanel.DNSRecord{ + {Type: "TXT", Name: "main.example.", TTL: 60, Value: "prio=5 hello", Priority: 0}, + } + dest.DNS.Zones[0].Records = []cpanel.DNSRecord{ + {Type: "TXT", Name: "main.example.", TTL: 60, Value: "hello", Priority: 5}, + } + + sec := sectionOf(t, DiffInventories(src, dest), "dns") + if len(sec.Changed) != 1 { + t.Fatalf("colliding canonical values hid a real record change: %+v", sec) + } +} + +// --------------------------------------------------------------------------- +// Determinism +// --------------------------------------------------------------------------- + +func TestDiffDeterministicOutput(t *testing.T) { + src := baseInventory() + dest := baseInventory() + dest.Domains = append(dest.Domains, + DomainEntry{Name: "zeta.example", Type: "addon"}, + DomainEntry{Name: "alpha.example", Type: "addon"}) + + d1 := DiffInventories(src, dest) + d2 := DiffInventories(src, dest) + sec1 := sectionOf(t, d1, "domains") + sec2 := sectionOf(t, d2, "domains") + if len(sec1.Added) != 2 || sec1.Added[0].Key != "alpha.example" || sec1.Added[1].Key != "zeta.example" { + t.Errorf("added not sorted: %+v", sec1.Added) + } + for i := range sec1.Added { + if sec1.Added[i] != sec2.Added[i] { + t.Errorf("non-deterministic output at %d", i) + } + } +} + +func TestDiffNoNilSlices(t *testing.T) { + d := DiffInventories(baseInventory(), baseInventory()) + if d.Warnings == nil { + t.Error("top-level warnings must not be nil") + } + for name, sec := range d.Sections { + if sec.Added == nil || sec.Removed == nil || sec.Changed == nil || sec.Warnings == nil { + t.Errorf("section %s has nil slices: %+v", name, sec) + } + } +} + +// PR 2B-2: the autoresponder content fields (body, from, is_html, +// start/stop, body_collected) must surface as CHANGED findings - before +// the body collector a silently different body was invisible to the diff. +func TestDiffAutoresponderBodyChanged(t *testing.T) { + src := baseInventory() + dest := baseInventory() + src.Autoresponders[0].Body = "On vacation.\n" + src.Autoresponders[0].BodyCollected = true + dest.Autoresponders[0].Body = "I am on vacation.\n" + dest.Autoresponders[0].BodyCollected = true + + sec := sectionOf(t, DiffInventories(src, dest), "autoresponders") + if len(sec.Changed) != 1 || sec.Changed[0].Field != "body" { + t.Fatalf("changed = %+v, want exactly one body change", sec.Changed) + } + if sec.Changed[0].Source != "On vacation.\n" || sec.Changed[0].Destination != "I am on vacation.\n" { + t.Errorf("changed values = %+v", sec.Changed[0]) + } +} + +func TestDiffAutoresponderBodyCollectedAsymmetry(t *testing.T) { + // One side collected the body, the other did not (pre-2B-2 artifact): + // the asymmetry must be visible, not silently equal. + src := baseInventory() + dest := baseInventory() + src.Autoresponders[0].Body = "Testo.\n" + src.Autoresponders[0].BodyCollected = true + + sec := sectionOf(t, DiffInventories(src, dest), "autoresponders") + if len(sec.Changed) == 0 { + t.Fatal("expected at least one changed field for the body_collected asymmetry") + } +} + +// go-review 2B-2 finding 5 (LOW): the diff must apply the same +// trailing-newline body normalization as the plan for COLLECTED bodies - +// a difference the plan treats as skip-equivalent must not surface as a +// changed finding in inventory diff. +func TestDiffAutoresponderBodyTrailingNewlineNotChanged(t *testing.T) { + src := baseInventory() + dest := baseInventory() + src.Autoresponders[0].Body = "Testo.\n" + src.Autoresponders[0].BodyCollected = true + dest.Autoresponders[0].Body = "Testo.\n\n\n" + dest.Autoresponders[0].BodyCollected = true + + sec := sectionOf(t, DiffInventories(src, dest), "autoresponders") + if len(sec.Changed) != 0 { + t.Fatalf("changed = %+v, want none (trailing-newline-only difference)", sec.Changed) + } +} diff --git a/internal/accountinventory/diff_write.go b/internal/accountinventory/diff_write.go new file mode 100644 index 00000000..8de11579 --- /dev/null +++ b/internal/accountinventory/diff_write.go @@ -0,0 +1,93 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// WriteDiffJSON writes the machine-readable diff, pretty-printed with a +// trailing newline (same conventions as WriteInventoryJSON). +func WriteDiffJSON(path string, d InventoryDiff) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + b, err := json.MarshalIndent(d, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal diff: %w", err) + } + b = append(b, '\n') + return os.WriteFile(path, b, 0o600) +} + +// WriteDiffMarkdown writes the human-readable diff. Every value cell goes +// through mdCell (pipe-escaped, rune-safe truncation), so redacted cron +// commands and long TXT/DKIM values stay table-safe and previews only. +func WriteDiffMarkdown(path string, d InventoryDiff) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + + fmt.Fprintf(&sb, "# Inventory Diff\n\n") + fmt.Fprintf(&sb, "- **Source**: %s\n", d.SourceFile) + fmt.Fprintf(&sb, "- **Destination**: %s\n", d.DestinationFile) + fmt.Fprintf(&sb, "- **Generated**: %s\n\n", d.GeneratedAt) + fmt.Fprintf(&sb, "**Summary**: %d section(s) compared - %d added, %d removed, %d changed, %d warning(s)\n\n", + d.Summary.SectionsCompared, d.Summary.Added, d.Summary.Removed, d.Summary.Changed, d.Summary.Warnings) + + if d.Summary.Added == 0 && d.Summary.Removed == 0 && d.Summary.Changed == 0 && d.Summary.Warnings == 0 { + sb.WriteString("No differences found.\n") + return os.WriteFile(path, []byte(sb.String()), 0o600) + } + + for _, w := range d.Warnings { + fmt.Fprintf(&sb, "> **Warning**: %s\n\n", w) + } + + for _, name := range diffSectionNames { + sec, ok := d.Sections[name] + if !ok { + continue + } + if len(sec.Added) == 0 && len(sec.Removed) == 0 && len(sec.Changed) == 0 && + len(sec.Warnings) == 0 && len(sec.Skipped) == 0 { + continue + } + fmt.Fprintf(&sb, "## %s - %d added, %d removed, %d changed\n\n", + name, len(sec.Added), len(sec.Removed), len(sec.Changed)) + for _, s := range sec.Skipped { + fmt.Fprintf(&sb, "> **Comparison skipped**: %s\n\n", s) + } + for _, w := range sec.Warnings { + fmt.Fprintf(&sb, "> **Warning**: %s\n\n", w) + } + if len(sec.Added) > 0 || len(sec.Removed) > 0 { + sb.WriteString("| +/- | Key | Detail |\n") + sb.WriteString("|---|-----|--------|\n") + for _, e := range sec.Added { + fmt.Fprintf(&sb, "| + | %s | %s |\n", mdCell(e.Key, 60), mdCell(e.Detail, 60)) + } + for _, e := range sec.Removed { + fmt.Fprintf(&sb, "| - | %s | %s |\n", mdCell(e.Key, 60), mdCell(e.Detail, 60)) + } + sb.WriteString("\n") + } + if len(sec.Changed) > 0 { + sb.WriteString("| Key | Field | Source | Destination |\n") + sb.WriteString("|-----|-------|--------|-------------|\n") + for _, c := range sec.Changed { + fmt.Fprintf(&sb, "| %s | %s | %s | %s |\n", + mdCell(c.Key, 50), mdCell(c.Field, 20), mdCell(c.Source, 60), mdCell(c.Destination, 60)) + } + sb.WriteString("\n") + } + } + + return os.WriteFile(path, []byte(sb.String()), 0o600) +} diff --git a/internal/accountinventory/diff_write_test.go b/internal/accountinventory/diff_write_test.go new file mode 100644 index 00000000..16ed3b5f --- /dev/null +++ b/internal/accountinventory/diff_write_test.go @@ -0,0 +1,135 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func sampleDiff(t *testing.T) InventoryDiff { + t.Helper() + src := baseInventory() + dest := baseInventory() + dest.Domains = append(dest.Domains, DomainEntry{Name: "new.example", Type: "addon"}) + dest.PHP.Items[0].Version = "ea-php83" + dest.Cron.Jobs[0].CommandSHA256 = "sha256:eeff" + dest.Cron.Jobs[0].CommandRedacted = "/bin/dump db | gzip --token=[REDACTED]" + d := DiffInventories(src, dest) + d.SourceFile = "inventory_source.json" + d.DestinationFile = "inventory_destination.json" + d.GeneratedAt = "2026-07-01T00:00:00Z" + return d +} + +func TestWriteDiffJSONNoNulls(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "inventory_diff.json") + if err := WriteDiffJSON(path, sampleDiff(t)); err != nil { + t.Fatalf("WriteDiffJSON: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + var walk func(v any, path string) + walk = func(v any, path string) { + switch val := v.(type) { + case nil: + t.Errorf("null at %s", path) + case map[string]any: + for k, c := range val { + walk(c, path+"."+k) + } + case []any: + for i, c := range val { + walk(c, path+"["+string(rune('0'+i%10))+"]") + } + } + } + walk(m, "$") + + for _, want := range []string{`"mode"`, `"inventory-diff"`, `"summary"`, `"sections"`, `"source_file"`, `"generated_at"`} { + if !strings.Contains(string(b), want) { + t.Errorf("JSON missing %s", want) + } + } +} + +func TestWriteDiffMarkdown(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "inventory_diff.md") + if err := WriteDiffMarkdown(path, sampleDiff(t)); err != nil { + t.Fatalf("WriteDiffMarkdown: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := string(b) + for _, want := range []string{ + "# Inventory Diff", + "new.example", // added domain visible + "ea-php81", "ea-php83", // changed values visible + "[REDACTED]", // cron detail redacted + } { + if !strings.Contains(s, want) { + t.Errorf("markdown missing %q", want) + } + } + // The piped cron command must be table-escaped. + if strings.Contains(s, " | gzip") { + t.Error("unescaped pipe leaked into a markdown table") + } + if !strings.Contains(s, `\|`) { + t.Error("expected escaped pipe in markdown") + } +} + +func TestWriteDiffMarkdownCleanDiff(t *testing.T) { + d := DiffInventories(baseInventory(), baseInventory()) + d.SourceFile, d.DestinationFile, d.GeneratedAt = "a.json", "b.json", "t" + dir := t.TempDir() + path := filepath.Join(dir, "diff.md") + if err := WriteDiffMarkdown(path, d); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(path) + if !strings.Contains(strings.ToLower(string(b)), "no differences") { + t.Errorf("clean diff should say so:\n%s", b) + } +} + +func TestDiffOutputNoSecretKeywordValues(t *testing.T) { + // The inputs are already redacted; the diff must not synthesize any + // secret-looking content of its own. + d := sampleDiff(t) + b, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + s := strings.ToLower(string(b)) + // "password"/"token" may appear ONLY inside redacted command strings + // (e.g. "--password=[REDACTED]"), never followed by a real value. + for _, frag := range []string{"password=", "token="} { + for idx := strings.Index(s, frag); idx >= 0; { + rest := s[idx+len(frag):] + if !strings.HasPrefix(rest, "[redacted]") { + t.Errorf("unredacted %s found in diff output near: %.60s", frag, s[idx:]) + } + next := strings.Index(rest, frag) + if next < 0 { + break + } + idx = idx + len(frag) + next + } + } +} diff --git a/internal/accountinventory/dnsapply.go b/internal/accountinventory/dnsapply.go new file mode 100644 index 00000000..1b424d35 --- /dev/null +++ b/internal/accountinventory/dnsapply.go @@ -0,0 +1,109 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" +) + +// DNS apply report and backup types (PR 6D). These are the OFFLINE +// artifact shapes: the report records what one `dns apply` run actually +// did; the backup archives the pre-write state so rollback can compare +// and restore. The types parallel the email apply types in emailapply.go. + +// Apply op statuses. +const ( + DNSOpApplied = "applied" + DNSOpSkipped = "skipped" + DNSOpManual = "manual" + DNSOpFailed = "failed" + DNSOpRefused = "refused_precondition" + DNSOpSkippedReplaceV1 = "skipped_replace_v1" +) + +// DNSApplyOpResult is one plan op with its apply outcome. +type DNSApplyOpResult struct { + PlanOp + Status string `json:"status"` + StatusReason string `json:"status_reason,omitempty"` +} + +// DNSApplyZoneResult is the result for one zone. +type DNSApplyZoneResult struct { + Zone string `json:"zone"` + Ops []DNSApplyOpResult `json:"ops"` + NewSerial string `json:"new_serial,omitempty"` +} + +// DNSApplySummary counts ops by status across all zones. +type DNSApplySummary struct { + Applied int `json:"applied"` + Skipped int `json:"skipped"` + Manual int `json:"manual"` + Failed int `json:"failed"` + Refused int `json:"refused_precondition"` +} + +// DNSApplyReport records what one `dns apply` run actually did. +type DNSApplyReport struct { + Mode string `json:"mode"` // "dns-apply-report" + FormatVersion int `json:"format_version"` // 1 + RunMode string `json:"run_mode"` // "apply" | "dry-run" + GeneratedAt string `json:"generated_at"` + PlanFile string `json:"plan_file,omitempty"` + PlanSHA256 string `json:"plan_sha256,omitempty"` + BackupFile string `json:"backup_file,omitempty"` + BackupSHA256 string `json:"backup_sha256,omitempty"` + BackupNote string `json:"backup_note,omitempty"` + Zones []DNSApplyZoneResult `json:"zones"` + Summary DNSApplySummary `json:"summary"` +} + +// SummarizeDNSResults recomputes the summary from the zone results. +func SummarizeDNSResults(zones []DNSApplyZoneResult) DNSApplySummary { + var s DNSApplySummary + for _, z := range zones { + for _, r := range z.Ops { + switch r.Status { + case DNSOpApplied: + s.Applied++ + case DNSOpSkipped, DNSOpSkippedReplaceV1: + s.Skipped++ + case DNSOpManual: + s.Manual++ + case DNSOpFailed: + s.Failed++ + case DNSOpRefused: + s.Refused++ + } + } + } + return s +} + +// --- backup ----------------------------------------------------------------- + +// DNSBackupZone archives one zone's pre-write state. Records are stored +// as cpanel.DNSRecord (the shape the collector already yields); Raw holds +// the verbatim UAPI parse_zone response for forensic replay. +type DNSBackupZone struct { + Zone string `json:"zone"` + Records []cpanel.DNSRecord `json:"records"` + Raw json.RawMessage `json:"raw"` // verbatim UAPI parse_zone response + Serial string `json:"serial"` // SOA serial at backup time +} + +// DNSApplyBackup is the pre-write state of every zone the plan touches. +// No backup file => no write. +type DNSApplyBackup struct { + Mode string `json:"mode"` // "dns-apply-backup" + FormatVersion int `json:"format_version"` // 1 + GeneratedAt string `json:"generated_at"` + PlanFile string `json:"plan_file,omitempty"` + PlanSHA256 string `json:"plan_sha256,omitempty"` + ReportFile string `json:"report_file"` // paired apply report path + Zones []DNSBackupZone `json:"zones"` +} diff --git a/internal/accountinventory/dnsapply_write.go b/internal/accountinventory/dnsapply_write.go new file mode 100644 index 00000000..871089ee --- /dev/null +++ b/internal/accountinventory/dnsapply_write.go @@ -0,0 +1,105 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// WriteDNSApplyBackupJSON writes the pre-write backup. Mode 0600: it +// holds verbatim zone records. +func WriteDNSApplyBackupJSON(path string, b DNSApplyBackup) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + data, err := json.MarshalIndent(b, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal dns apply backup: %w", err) + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} + +// WriteDNSApplyReportJSON writes the machine-readable apply report. +func WriteDNSApplyReportJSON(path string, r DNSApplyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal dns apply report: %w", err) + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} + +// WriteDNSApplyReportMarkdown writes the human-readable apply report. +// Failures and refusals come first: they are what the operator must act on. +func WriteDNSApplyReportMarkdown(path string, r DNSApplyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + + fmt.Fprintf(&sb, "# DNS Apply Report\n\n") + fmt.Fprintf(&sb, "- **Run mode**: %s\n", r.RunMode) + if r.PlanFile != "" { + fmt.Fprintf(&sb, "- **Plan**: %s (sha256 %s)\n", r.PlanFile, r.PlanSHA256) + } + if r.BackupFile != "" { + fmt.Fprintf(&sb, "- **Backup**: %s (sha256 %s)\n", r.BackupFile, r.BackupSHA256) + } else if r.BackupNote != "" { + fmt.Fprintf(&sb, "- **Backup**: none, %s\n", r.BackupNote) + } + fmt.Fprintf(&sb, "- **Generated**: %s\n\n", r.GeneratedAt) + fmt.Fprintf(&sb, "**Summary**: %d applied, %d skipped, %d manual, %d failed, %d refused\n\n", + r.Summary.Applied, r.Summary.Skipped, r.Summary.Manual, + r.Summary.Failed, r.Summary.Refused) + + if len(r.Zones) == 0 { + sb.WriteString("No zones processed.\n") + return os.WriteFile(path, []byte(sb.String()), 0o600) + } + + order := []string{DNSOpFailed, DNSOpRefused, DNSOpApplied, DNSOpManual, DNSOpSkipped, DNSOpSkippedReplaceV1} + + for _, z := range r.Zones { + fmt.Fprintf(&sb, "## Zone %s\n\n", z.Zone) + if z.NewSerial != "" { + fmt.Fprintf(&sb, "New serial: %s\n\n", z.NewSerial) + } + + for _, status := range order { + var rows []DNSApplyOpResult + for _, op := range z.Ops { + if op.Status == status { + rows = append(rows, op) + } + } + if len(rows) == 0 { + continue + } + fmt.Fprintf(&sb, "### %s (%d)\n\n", strings.ToUpper(strings.ReplaceAll(status, "_", " ")), len(rows)) + sb.WriteString("| Type | Name | Action | Detail | Note |\n|------|------|--------|--------|------|\n") + for _, op := range rows { + detail := op.Reason + if detail == "" { + detail = displayRecords(op.PlanOp) + } + note := op.StatusReason + fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s |\n", + mdCell(op.Type, 8), mdCell(op.Name, 60), + mdCell(op.Action, 10), mdCell(detail, 90), + mdCell(note, 110)) + } + sb.WriteString("\n") + } + } + + return os.WriteFile(path, []byte(sb.String()), 0o600) +} diff --git a/internal/accountinventory/dnsplan.go b/internal/accountinventory/dnsplan.go new file mode 100644 index 00000000..99f66701 --- /dev/null +++ b/internal/accountinventory/dnsplan.go @@ -0,0 +1,662 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "fmt" + "net/netip" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" +) + +// DNS import plan (PR 6B). BuildDNSPlan is fully offline: it consumes +// two normalized inventories and produces a reviewable plan of what a +// future apply (PR 6D) would write into the DESTINATION account's DNS +// zones. It never connects anywhere and never generates delete ops for +// destination records; the design and its safety rules live in +// docs/dev/PR6A_DNS_IMPORT_DESIGN.md and PR6B_PRE_CAPTURES.md. + +const ( + ActionAdd = "add" // rrset missing on destination + ActionReplace = "replace" // rrset present with different values + ActionManual = "manual" // the tool refuses to touch it (terminal) + ActionSkip = "skip" // identical after translation, or excluded +) + +// planWriteTTLCap bounds the TTL written to the destination so a wrong +// record or a rollback cannot live in resolver caches for hours. +const planWriteTTLCap = 3600 + +// txtSegmentLen is the RFC 1035 character-string limit; TXT data is +// written as pre-split segments, mirroring what parse_zone returns. +const txtSegmentLen = 255 + +// hostValidationPrefixes are owner-name leftmost labels of transient, +// host-specific validation records (AutoSSL / DCV) that must never be +// migrated (real-server finding, PR6B_PRE_CAPTURES.md). +var hostValidationPrefixes = []string{"_acme-challenge", "_cpanel-dcv-test-record"} + +// actionableTypes are the record types the tool knows how to round-trip. +var actionableTypes = map[string]bool{"A": true, "AAAA": true, "CNAME": true, "MX": true, "TXT": true} + +// PlanRecord is one desired record in the shape DNS::mass_edit_zone +// accepts (dname/ttl/record_type/data). Names are stored canonical +// (absolute, lowercase, trailing dot); the 6D writer converts to the +// server's expected format. +type PlanRecord struct { + Name string `json:"name"` + Type string `json:"type"` + TTL int `json:"ttl"` + Data []string `json:"data"` +} + +// PlanOp is the decision for one source rrset (zone, type, name). +type PlanOp struct { + Action string `json:"action"` + Type string `json:"type"` + Name string `json:"name"` + Reason string `json:"reason,omitempty"` + Records []PlanRecord `json:"records,omitempty"` + SourceValues []string `json:"source_values,omitempty"` + DestinationValues []string `json:"destination_values,omitempty"` + TTLCapped bool `json:"ttl_capped,omitempty"` +} + +// PlanRRSetInfo describes a destination-only rrset: listed so a human +// sees it, never deleted (additive posture). +type PlanRRSetInfo struct { + Type string `json:"type"` + Name string `json:"name"` + Values []string `json:"values"` +} + +type PlanZone struct { + Zone string `json:"zone"` + Ops []PlanOp `json:"ops"` + Informational []PlanRRSetInfo `json:"informational,omitempty"` + PolicyFindings []string `json:"policy_findings,omitempty"` +} + +// ManualZone is a zone the plan refuses to compute ops for. +type ManualZone struct { + Zone string `json:"zone"` + Reason string `json:"reason"` +} + +type PlanSummary struct { + Add int `json:"add"` + Replace int `json:"replace"` + Manual int `json:"manual"` + Skip int `json:"skip"` + Informational int `json:"informational"` +} + +type DNSPlan struct { + Mode string `json:"mode"` + FormatVersion int `json:"format_version"` + GeneratedAt string `json:"generated_at"` + SourceFile string `json:"source_file,omitempty"` + SourceSHA256 string `json:"source_sha256,omitempty"` + DestinationFile string `json:"destination_file,omitempty"` + DestinationSHA256 string `json:"destination_sha256,omitempty"` + PolicyFile string `json:"policy_file,omitempty"` + IPMap map[string]string `json:"ip_map"` + Zones []PlanZone `json:"zones"` + ManualZones []ManualZone `json:"manual_zones,omitempty"` + NonDNSBlockers []string `json:"non_dns_blockers,omitempty"` + Summary PlanSummary `json:"summary"` +} + +// canonDNSName canonicalizes an owner name or target relative to a +// zone: lowercase, absolute FQDN with trailing dot. Real parse_zone +// data mixes absolute apex names with relative non-apex names +// (PR6B_PRE_CAPTURES.md); RFC semantics qualify relative names against +// the zone origin. +func canonDNSName(name, zone string) string { + n := strings.ToLower(strings.TrimSpace(name)) + z := strings.ToLower(strings.TrimSpace(zone)) + z = strings.TrimSuffix(z, ".") + "." + switch n { + case "", "@": + return z + } + if strings.HasSuffix(n, ".") { + return n + } + // A bare name equal to the zone is the apex, not a relative name: + // real parse_zone emits the apex WITH the dot, but a degraded + // upstream (hand-edited zone, different collector) must not turn + // "example.com" into "example.com.example.com.". + if n == strings.TrimSuffix(z, ".") { + return z + } + return n + "." + z +} + +// canonIPString returns the canonical textual form of an IP literal, +// or the lowercased input when it does not parse - failing safe: an +// unparsable address can never match an ip-map entry, so its rrset +// lands in manual. +func canonIPString(s string) string { + if a, err := netip.ParseAddr(strings.TrimSpace(s)); err == nil { + return a.String() + } + return strings.ToLower(strings.TrimSpace(s)) +} + +// leftmostLabel returns the first DNS label of a canonical name. +func leftmostLabel(name string) string { + if i := strings.IndexByte(name, '.'); i >= 0 { + return name[:i] + } + return name +} + +// splitTXTSegments splits TXT data into RFC 1035 character-string +// segments (<=255 bytes), the format both parse_zone and mass_edit_zone +// use. Cuts land on UTF-8 rune boundaries: a segment holding half a +// multi-byte rune would be invalid UTF-8 and encoding/json silently +// rewrites invalid bytes to U+FFFD when the plan is persisted - the +// rejoined value would no longer equal the source. Callers must reject +// non-UTF-8 input first (classify sends it to manual). +func splitTXTSegments(s string) []string { + if s == "" { + return []string{""} + } + var out []string + for len(s) > txtSegmentLen { + cut := txtSegmentLen + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + if cut == 0 { // degenerate: no boundary in the window + cut = txtSegmentLen + } + out = append(out, s[:cut]) + s = s[cut:] + } + return append(out, s) +} + +// rrsetKey identifies an rrset inside one zone. +type rrsetKey struct { + Type string + Name string +} + +// planValue renders one record's comparable value (TTL deliberately +// excluded: the plan acts on substance, TTL-only drift is a skip). +func planValue(r cpanel.DNSRecord, zone string) string { + switch r.Type { + case "A", "AAAA": + return canonIPString(r.Address) + case "CNAME", "NS": + return canonDNSName(r.Target, zone) + case "MX": + return strconv.Itoa(r.Priority) + "\x00" + canonDNSName(r.Exchange, zone) + case "TXT": + return r.TxtData + default: + return r.Value + } +} + +func groupRRSets(records []cpanel.DNSRecord, zone string) map[rrsetKey][]cpanel.DNSRecord { + g := map[rrsetKey][]cpanel.DNSRecord{} + for _, r := range records { + k := rrsetKey{Type: strings.ToUpper(r.Type), Name: canonDNSName(r.Name, zone)} + g[k] = append(g[k], r) + } + return g +} + +func sortedValues(records []cpanel.DNSRecord, zone string) []string { + vals := make([]string, 0, len(records)) + for _, r := range records { + vals = append(vals, planValue(r, zone)) + } + sort.Strings(vals) + return vals +} + +func valuesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// BuildDNSPlan computes the DNS import plan. policy is optional context +// (findings are cross-referenced, never gating - see the 6A design for +// why gating on the policy status would deadlock every real migration). +// ipMap maps source addresses to destination addresses; an A/AAAA rrset +// with any unmapped value becomes manual, without exception. +func BuildDNSPlan(src, dest NormalizedInventory, policy *PolicyReport, ipMap map[string]string) (DNSPlan, error) { + if !src.DNS.Available { + return DNSPlan{}, fmt.Errorf("source DNS section unavailable - re-run the inventory") + } + if !dest.DNS.Available { + return DNSPlan{}, fmt.Errorf("destination DNS section unavailable - re-run the inventory") + } + // Both sides of every mapping go through netip canonicalization so + // lookups are by address identity, not by spelling. + canonMap := make(map[string]string, len(ipMap)) + for k, v := range ipMap { + canonMap[canonIPString(k)] = canonIPString(v) + } + ipMap = canonMap + + // Zones is seeded non-nil so the JSON stays array-typed ("[]", not + // null) even when every source zone lands in ManualZones - the + // normal state of a fresh migration (diff.go convention). + plan := DNSPlan{ + Mode: "dns-import-plan", + FormatVersion: 1, + IPMap: ipMap, + Zones: []PlanZone{}, + } + + destZones := map[string]DNSZoneResult{} + for _, z := range dest.DNS.Zones { + destZones[strings.ToLower(z.Zone)] = z + } + + for _, sz := range src.DNS.Zones { + zoneName := strings.ToLower(sz.Zone) + if !sz.Available { + plan.ManualZones = append(plan.ManualZones, ManualZone{ + Zone: zoneName, Reason: "zone unavailable on source - re-run the source inventory"}) + continue + } + dz, ok := destZones[zoneName] + if !ok { + plan.ManualZones = append(plan.ManualZones, ManualZone{ + Zone: zoneName, Reason: "zone missing on destination - create it via WHM/park first, then re-run"}) + continue + } + if !dz.Available { + plan.ManualZones = append(plan.ManualZones, ManualZone{ + Zone: zoneName, Reason: "zone unavailable on destination - re-run the destination inventory"}) + continue + } + pz := buildZonePlan(zoneName, sz.Records, dz.Records, ipMap) + pz.PolicyFindings = zonePolicyFindings(policy, zoneName) + plan.Zones = append(plan.Zones, pz) + } + + plan.NonDNSBlockers = nonDNSBlockers(policy) + + sort.Slice(plan.Zones, func(i, j int) bool { return plan.Zones[i].Zone < plan.Zones[j].Zone }) + sort.Slice(plan.ManualZones, func(i, j int) bool { return plan.ManualZones[i].Zone < plan.ManualZones[j].Zone }) + + for _, z := range plan.Zones { + for _, op := range z.Ops { + switch op.Action { + case ActionAdd: + plan.Summary.Add++ + case ActionReplace: + plan.Summary.Replace++ + case ActionManual: + plan.Summary.Manual++ + case ActionSkip: + plan.Summary.Skip++ + } + } + plan.Summary.Informational += len(z.Informational) + } + return plan, nil +} + +func buildZonePlan(zone string, srcRecords, destRecords []cpanel.DNSRecord, ipMap map[string]string) PlanZone { + pz := PlanZone{Zone: zone, Ops: []PlanOp{}} + srcSets := groupRRSets(srcRecords, zone) + destSets := groupRRSets(destRecords, zone) + + // Owner names that carry a CNAME on either side: nothing else may + // coexist there (and the tool never deletes), so any cross-type op + // at such a name is forced manual. + cnameNames := map[string]bool{} + typesAt := map[string]map[string]bool{} + for _, sets := range []map[rrsetKey][]cpanel.DNSRecord{srcSets, destSets} { + for k := range sets { + if k.Type == "CNAME" { + cnameNames[k.Name] = true + } + if typesAt[k.Name] == nil { + typesAt[k.Name] = map[string]bool{} + } + typesAt[k.Name][k.Type] = true + } + } + crossTypeConflict := func(k rrsetKey) bool { + if !cnameNames[k.Name] { + return false + } + for t := range typesAt[k.Name] { + if t != k.Type { + return true + } + } + return false + } + + for k, records := range srcSets { + op := PlanOp{Type: k.Type, Name: k.Name, SourceValues: sortedValues(records, zone)} + if dv, ok := destSets[k]; ok { + op.DestinationValues = sortedValues(dv, zone) + } + classify(&op, k, records, destSets, ipMap, zone, crossTypeConflict) + pz.Ops = append(pz.Ops, op) + } + + for k, records := range destSets { + if _, ok := srcSets[k]; ok { + continue + } + pz.Informational = append(pz.Informational, PlanRRSetInfo{ + Type: k.Type, Name: k.Name, Values: sortedValues(records, zone)}) + } + + sort.Slice(pz.Ops, func(i, j int) bool { + if pz.Ops[i].Name != pz.Ops[j].Name { + return pz.Ops[i].Name < pz.Ops[j].Name + } + return pz.Ops[i].Type < pz.Ops[j].Type + }) + sort.Slice(pz.Informational, func(i, j int) bool { + if pz.Informational[i].Name != pz.Informational[j].Name { + return pz.Informational[i].Name < pz.Informational[j].Name + } + return pz.Informational[i].Type < pz.Informational[j].Type + }) + return pz +} + +// classify applies the 6A rule table to one source rrset, in order: +// exclusions first (SOA, host-validation, NS, unsupported, CNAME +// conflicts), then translation gates (unmapped A/AAAA, TXT with mapped +// IPs), then the value comparison that yields skip/add/replace. +func classify(op *PlanOp, k rrsetKey, records []cpanel.DNSRecord, destSets map[rrsetKey][]cpanel.DNSRecord, ipMap map[string]string, zone string, crossTypeConflict func(rrsetKey) bool) { + switch { + case k.Type == "SOA": + op.Action, op.Reason = ActionSkip, "SOA is server-managed - never compared or written" + return + case isHostValidationName(k.Name): + op.Action, op.Reason = ActionSkip, "host-specific validation record (AutoSSL/DCV) - not migrated" + return + case k.Type == "NS": + if dv, ok := destSets[k]; ok && valuesEqual(op.SourceValues, sortedValues(dv, zone)) { + op.Action = ActionSkip + return + } + op.Action, op.Reason = ActionManual, "NS/delegation is registrar/WHM territory - never written" + return + case !actionableTypes[k.Type]: + op.Action, op.Reason = ActionManual, fmt.Sprintf("record type %s is not supported for import", k.Type) + return + case crossTypeConflict(k): + op.Action, op.Reason = ActionManual, "CNAME cannot coexist with other types at the same name - resolve by hand" + return + } + + translated, unmapped := translateRecords(records, k.Type, ipMap, zone) + if len(unmapped) > 0 { + op.Action = ActionManual + op.Reason = fmt.Sprintf("unmapped address(es) %s - add --ip-map entries (identity mapping to copy verbatim)", + strings.Join(unmapped, ", ")) + return + } + if k.Type == "TXT" { + for _, r := range records { + if !utf8.ValidString(r.TxtData) { + op.Action = ActionManual + op.Reason = "TXT value is not valid UTF-8 - it cannot be represented faithfully in the plan file" + return + } + } + // Substring match is deliberately naive: a false positive only + // costs a manual review (fail-safe), a miss would ship a stale + // address inside SPF. + matched := map[string]bool{} + for _, r := range records { + for from := range ipMap { + if strings.Contains(r.TxtData, from) { + matched[from] = true + } + } + } + if len(matched) > 0 { + // Real-data refinement (docs/dev/PR7A_REAL_SMOKE.md): when the + // destination rrset ALREADY equals the source values with every + // mapped address substituted, there is nothing to rewrite - + // refusing it as manual was a pure false positive (identity + // maps included). Any other destination state keeps failing + // safe into manual below. + if dv, ok := destSets[k]; ok && + valuesEqual(translateTXTValues(records, ipMap), sortedValues(dv, zone)) { + op.Action = ActionSkip + op.Reason = "destination already matches the ip-map translation - nothing to rewrite" + return + } + addrs := make([]string, 0, len(matched)) + for a := range matched { + addrs = append(addrs, a) + } + sort.Strings(addrs) + op.Action = ActionManual + op.Reason = fmt.Sprintf("TXT value contains mapped source address(es) %s (SPF?) - rewrite by hand", + strings.Join(addrs, ", ")) + return + } + } + + desiredValues := make([]string, 0, len(translated)) + for _, tr := range translated { + desiredValues = append(desiredValues, tr.value) + } + sort.Strings(desiredValues) + + if dv, ok := destSets[k]; ok { + if valuesEqual(desiredValues, sortedValues(dv, zone)) { + op.Action = ActionSkip + return + } + op.Action = ActionReplace + } else { + op.Action = ActionAdd + } + for _, tr := range translated { + op.Records = append(op.Records, tr.record) + if tr.capped { + op.TTLCapped = true + } + } + sort.Slice(op.Records, func(i, j int) bool { + return strings.Join(op.Records[i].Data, "\x00") < strings.Join(op.Records[j].Data, "\x00") + }) +} + +// translateTXTValues renders the sorted comparable values of a TXT rrset +// with every mapped source address substituted by its destination address. +// The substitution is SIMULTANEOUS and single-pass over the original +// string: substituted output is never re-scanned. A sequential +// ReplaceAll-per-entry loop would cascade - a cyclic map (two accounts +// swapping servers: A->B plus B->A) would cancel itself out, regenerate the +// original value, and falsely skip a stale, unmigrated destination. +// Matching stays as naive as the detection that precedes it (plain +// substring, longer addresses first so an address that prefixes another +// cannot be corrupted by it): a wrong result simply fails the equality +// check and the rrset stays manual - fail-safe. +func translateTXTValues(records []cpanel.DNSRecord, ipMap map[string]string) []string { + froms := make([]string, 0, len(ipMap)) + for from := range ipMap { + froms = append(froms, from) + } + sort.Slice(froms, func(i, j int) bool { + if len(froms[i]) != len(froms[j]) { + return len(froms[i]) > len(froms[j]) + } + return froms[i] < froms[j] + }) + vals := make([]string, 0, len(records)) + for _, r := range records { + vals = append(vals, substituteOnce(r.TxtData, froms, ipMap)) + } + sort.Strings(vals) + return vals +} + +// substituteOnce performs one left-to-right pass over s, replacing each +// occurrence of any mapped address with its target exactly once; the +// replacement text is emitted, never re-matched. +func substituteOnce(s string, froms []string, ipMap map[string]string) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); { + replaced := false + for _, from := range froms { // longest-first, deterministic + if strings.HasPrefix(s[i:], from) { + b.WriteString(ipMap[from]) + i += len(from) + replaced = true + break + } + } + if !replaced { + b.WriteByte(s[i]) + i++ + } + } + return b.String() +} + +func isHostValidationName(canonical string) bool { + label := leftmostLabel(canonical) + for _, p := range hostValidationPrefixes { + if label == p { + return true + } + } + return false +} + +type translatedRecord struct { + record PlanRecord + value string + capped bool +} + +// translateRecords builds the desired write-shaped records for one +// rrset, applying the ip-map to A/AAAA and the TTL cap to everything. +// It returns the sorted list of unmapped addresses when translation is +// impossible. +func translateRecords(records []cpanel.DNSRecord, typ string, ipMap map[string]string, zone string) ([]translatedRecord, []string) { + var out []translatedRecord + unmappedSet := map[string]bool{} + for _, r := range records { + ttl, capped := capTTL(r.TTL) + rec := PlanRecord{Name: canonDNSName(r.Name, zone), Type: typ, TTL: ttl} + var value string + switch typ { + case "A", "AAAA": + // Lookups go through netip canonical form so textually + // different spellings of the same address (IPv6 zero + // compression, case) still match; an unparsable address + // fails safe into manual. + addr := canonIPString(r.Address) + to, ok := ipMap[addr] + if !ok { + unmappedSet[addr] = true + continue + } + to = canonIPString(to) + rec.Data = []string{to} + value = to + case "CNAME": + t := canonDNSName(r.Target, zone) + rec.Data = []string{t} + value = t + case "MX": + ex := canonDNSName(r.Exchange, zone) + rec.Data = []string{strconv.Itoa(r.Priority), ex} + value = strconv.Itoa(r.Priority) + "\x00" + ex + case "TXT": + rec.Data = splitTXTSegments(r.TxtData) + value = r.TxtData + } + out = append(out, translatedRecord{record: rec, value: value, capped: capped}) + } + var unmapped []string + for a := range unmappedSet { + unmapped = append(unmapped, a) + } + sort.Strings(unmapped) + return out, unmapped +} + +// capTTL implements the design's min(source TTL, 3600) with one +// documented deviation: TTL <= 0 is treated as MISSING collector data +// (parse_zone always carries a positive ttl on real records; a zero +// here means the field never decoded) and defaults to the cap rather +// than writing an accidental "do not cache". The capped flag surfaces +// both cases in the plan so the reviewer sees the rewrite. +func capTTL(ttl int) (int, bool) { + if ttl <= 0 || ttl > planWriteTTLCap { + return planWriteTTLCap, true + } + return ttl, false +} + +// zonePolicyFindings extracts the POL-DNS-* findings that reference the +// zone, formatted for the plan (context for the reviewer, never a gate). +func zonePolicyFindings(policy *PolicyReport, zone string) []string { + if policy == nil { + return nil + } + var out []string + prefix := "zone " + zone + for _, f := range policy.Findings { + if f.Section != "dns" { + continue + } + ref := f.SourceRef + if ref == "" { + ref = f.DestinationRef + } + if ref == "" { + ref = f.Detail + } + if ref != prefix && !strings.HasPrefix(ref, prefix+" ") { + continue + } + out = append(out, fmt.Sprintf("%s [%s] %s", f.ID, f.Severity, ref)) + } + sort.Strings(out) + return out +} + +// nonDNSBlockers lists blocker findings outside the dns section: they +// concern other migration steps and are surfaced as context only. +func nonDNSBlockers(policy *PolicyReport) []string { + if policy == nil { + return nil + } + var out []string + for _, f := range policy.Findings { + if f.Severity == SeverityBlocker && f.Section != "dns" { + out = append(out, fmt.Sprintf("%s (%s)", f.ID, f.Section)) + } + } + sort.Strings(out) + return out +} diff --git a/internal/accountinventory/dnsplan_safety_test.go b/internal/accountinventory/dnsplan_safety_test.go new file mode 100644 index 00000000..b9184bb8 --- /dev/null +++ b/internal/accountinventory/dnsplan_safety_test.go @@ -0,0 +1,52 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestDNSPlanSourcesContainNoWriteCalls pins the read-only invariant of +// the plan-builder line of work (PR 6B/6C): no dnsplan source may +// invoke a DNS write primitive in code (comments may name them for +// documentation). Only the future 6D apply files may. The cron safety +// test scans cron-specific literals and does NOT cover these - this is +// the DNS equivalent, per PR6A_DNS_IMPORT_DESIGN.md. +func TestDNSPlanSourcesContainNoWriteCalls(t *testing.T) { + writeCalls := []string{ + "mass_edit_zone", + "add_zone_record", + "edit_zone_record", + "remove_zone_record", + "resetzone", + } + files, err := filepath.Glob("dnsplan*.go") + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no dnsplan*.go files found - glob broken?") + } + for _, f := range files { + if f == "dnsplan_safety_test.go" { + continue // this file names the verbs on purpose + } + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(b), "\n") { + code, _, _ := strings.Cut(strings.TrimSpace(line), "//") + for _, verb := range writeCalls { + if strings.Contains(code, verb) { + t.Errorf("%s:%d references DNS write call %q in code - the plan line of work is read-only", + f, i+1, verb) + } + } + } + } +} diff --git a/internal/accountinventory/dnsplan_test.go b/internal/accountinventory/dnsplan_test.go new file mode 100644 index 00000000..d8d09fd9 --- /dev/null +++ b/internal/accountinventory/dnsplan_test.go @@ -0,0 +1,572 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "strings" + "testing" + "unicode/utf8" +) + +// Fixture helpers reproduce the REAL parse_zone shape captured on cPanel +// 110.0.131 (docs/dev/PR6B_PRE_CAPTURES.md): apex owner names are +// absolute FQDNs WITH trailing dot, non-apex names are RELATIVE without +// dot, CNAME/MX targets are absolute with dot, DKIM TXT values arrive +// joined by the collector. + +func planZone(zone string, records ...cpanel.DNSRecord) DNSZoneResult { + return DNSZoneResult{Available: true, Zone: zone, Method: "uapi", Records: records} +} + +func planInventory(side, host string, zones ...DNSZoneResult) NormalizedInventory { + inv := NewEmptyInventory("u", host, side) + inv.DNS.Available = true + inv.DNS.Zones = zones + return inv +} + +func aRec(name, addr string, ttl int) cpanel.DNSRecord { + return cpanel.DNSRecord{Type: "A", Name: name, TTL: ttl, Address: addr, Value: addr} +} + +func cnameRec(name, target string, ttl int) cpanel.DNSRecord { + return cpanel.DNSRecord{Type: "CNAME", Name: name, TTL: ttl, Target: target, Value: target} +} + +func mxRec(name, exchange string, prio, ttl int) cpanel.DNSRecord { + return cpanel.DNSRecord{Type: "MX", Name: name, TTL: ttl, Exchange: exchange, Priority: prio, Value: exchange} +} + +func txtRec(name, data string, ttl int) cpanel.DNSRecord { + return cpanel.DNSRecord{Type: "TXT", Name: name, TTL: ttl, TxtData: data, Value: data} +} + +func nsRec(name, target string, ttl int) cpanel.DNSRecord { + return cpanel.DNSRecord{Type: "NS", Name: name, TTL: ttl, Target: target, Value: target} +} + +// soaRec lives here (not in checklist_test.go) so the checklist test +// sources stay free of any internal/cpanel reference: the offline-by- +// construction safety test globs checklist*.go and forbids that import. +func soaRec(name, value string, ttl int) cpanel.DNSRecord { + return cpanel.DNSRecord{Type: "SOA", Name: name, TTL: ttl, Value: value} +} + +// findOp returns the single op for (type, canonical name), failing the +// test when absent or duplicated. +func findOp(t *testing.T, z PlanZone, typ, name string) PlanOp { + t.Helper() + var found []PlanOp + for _, op := range z.Ops { + if op.Type == typ && op.Name == name { + found = append(found, op) + } + } + if len(found) != 1 { + t.Fatalf("ops for %s %s = %d, want exactly 1 (ops: %+v)", typ, name, len(found), z.Ops) + } + return found[0] +} + +func singleZonePlan(t *testing.T, src, dest NormalizedInventory, ipMap map[string]string) (DNSPlan, PlanZone) { + t.Helper() + p, err := BuildDNSPlan(src, dest, nil, ipMap) + if err != nil { + t.Fatal(err) + } + if len(p.Zones) != 1 { + t.Fatalf("zones = %d, want 1", len(p.Zones)) + } + return p, p.Zones[0] +} + +func TestBuildDNSPlanAddMissingMappedA(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", + aRec("example.com.", "203.0.113.11", 14400), + aRec("ftp", "203.0.113.11", 14400))) + dest := planInventory("destination", "192.0.2.2", + planZone("example.com", + aRec("example.com.", "203.0.113.12", 14400))) + + _, z := singleZonePlan(t, src, dest, map[string]string{"203.0.113.11": "203.0.113.12"}) + + // Apex A: translated source value equals destination -> skip. + if op := findOp(t, z, "A", "example.com."); op.Action != ActionSkip { + t.Errorf("apex A action = %s, want skip (translated equal)", op.Action) + } + // ftp is relative in the real format -> canonicalized against the zone. + op := findOp(t, z, "A", "ftp.example.com.") + if op.Action != ActionAdd { + t.Fatalf("ftp A action = %s, want add", op.Action) + } + if len(op.Records) != 1 || op.Records[0].Data[0] != "203.0.113.12" { + t.Errorf("ftp A record not translated: %+v", op.Records) + } +} + +func TestBuildDNSPlanUnmappedAddressIsManual(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", + aRec("example.com.", "203.0.113.11", 14400), + aRec("ext", "203.0.113.9", 300))) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + + // Map covers the server IP but NOT the external one. + _, z := singleZonePlan(t, src, dest, map[string]string{"203.0.113.11": "203.0.113.12"}) + + if op := findOp(t, z, "A", "ext.example.com."); op.Action != ActionManual { + t.Errorf("unmapped A action = %s, want manual", op.Action) + } else if !strings.Contains(op.Reason, "203.0.113.9") { + t.Errorf("manual reason should name the unmapped address, got %q", op.Reason) + } + // Identity mapping authorizes a verbatim copy. + src2 := planInventory("source", "192.0.2.1", + planZone("example.com", aRec("ext", "203.0.113.9", 300))) + _, z2 := singleZonePlan(t, src2, dest, map[string]string{"203.0.113.9": "203.0.113.9"}) + if op := findOp(t, z2, "A", "ext.example.com."); op.Action != ActionAdd { + t.Errorf("identity-mapped A action = %s, want add", op.Action) + } +} + +func TestBuildDNSPlanNoIPMapMakesEveryAManual(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", aRec("example.com.", "203.0.113.11", 14400))) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + + _, z := singleZonePlan(t, src, dest, nil) + if op := findOp(t, z, "A", "example.com."); op.Action != ActionManual { + t.Errorf("A without any ip-map = %s, want manual", op.Action) + } +} + +func TestBuildDNSPlanNSAndSOAAreNeverTouched(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", + cpanel.DNSRecord{Type: "SOA", Name: "example.com.", TTL: 86400, Value: "ns.old.example."}, + nsRec("example.com.", "ns1.old.example.", 86400))) + dest := planInventory("destination", "192.0.2.2", + planZone("example.com", + cpanel.DNSRecord{Type: "SOA", Name: "example.com.", TTL: 86400, Value: "ns.new.example."}, + nsRec("example.com.", "ns1.new.example.", 86400))) + + _, z := singleZonePlan(t, src, dest, nil) + if op := findOp(t, z, "SOA", "example.com."); op.Action != ActionSkip { + t.Errorf("SOA action = %s, want skip (server-managed)", op.Action) + } + if op := findOp(t, z, "NS", "example.com."); op.Action != ActionManual { + t.Errorf("NS action = %s, want manual (delegation)", op.Action) + } +} + +func TestBuildDNSPlanReplaceChangedCNAME(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", cnameRec("cdn", "edge.example.net.", 14400))) + dest := planInventory("destination", "192.0.2.2", + planZone("example.com", cnameRec("cdn", "old.example.net.", 14400))) + + _, z := singleZonePlan(t, src, dest, nil) + op := findOp(t, z, "CNAME", "cdn.example.com.") + if op.Action != ActionReplace { + t.Fatalf("changed CNAME action = %s, want replace", op.Action) + } + if op.Records[0].Data[0] != "edge.example.net." { + t.Errorf("replace desired data = %v", op.Records[0].Data) + } +} + +func TestBuildDNSPlanCNAMECrossTypeConflictIsManual(t *testing.T) { + // Source has CNAME www; destination has A www -> adding would create + // an invalid zone (never-delete posture). Both directions manual. + src := planInventory("source", "192.0.2.1", + planZone("example.com", cnameRec("www", "example.com.", 14400))) + dest := planInventory("destination", "192.0.2.2", + planZone("example.com", aRec("www", "203.0.113.12", 14400))) + + _, z := singleZonePlan(t, src, dest, nil) + if op := findOp(t, z, "CNAME", "www.example.com."); op.Action != ActionManual { + t.Errorf("CNAME-over-A action = %s, want manual", op.Action) + } + + // Reverse: source A www, destination CNAME www. + src2 := planInventory("source", "192.0.2.1", + planZone("example.com", aRec("www", "203.0.113.11", 14400))) + dest2 := planInventory("destination", "192.0.2.2", + planZone("example.com", cnameRec("www", "example.com.", 14400))) + _, z2 := singleZonePlan(t, src2, dest2, map[string]string{"203.0.113.11": "203.0.113.12"}) + if op := findOp(t, z2, "A", "www.example.com."); op.Action != ActionManual { + t.Errorf("A-under-CNAME action = %s, want manual", op.Action) + } +} + +func TestBuildDNSPlanTXTWithMappedIPIsManual(t *testing.T) { + // Real SPF from the captures: contains the source server IP. + src := planInventory("source", "192.0.2.1", + planZone("example.com", + txtRec("example.com.", "v=spf1 +a +mx +ip4:203.0.113.11 ~all", 14400), + txtRec("_dmarc", "v=DMARC1; p=none;", 14400))) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + + _, z := singleZonePlan(t, src, dest, map[string]string{"203.0.113.11": "203.0.113.12"}) + if op := findOp(t, z, "TXT", "example.com."); op.Action != ActionManual { + t.Errorf("SPF TXT action = %s, want manual (contains mapped IP)", op.Action) + } + if op := findOp(t, z, "TXT", "_dmarc.example.com."); op.Action != ActionAdd { + t.Errorf("DMARC TXT action = %s, want add", op.Action) + } +} + +func TestBuildDNSPlanLongTXTIsSegmented(t *testing.T) { + long := strings.Repeat("x", 300) + src := planInventory("source", "192.0.2.1", + planZone("example.com", txtRec("default._domainkey", "v=DKIM1; p="+long, 14400))) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + + _, z := singleZonePlan(t, src, dest, nil) + op := findOp(t, z, "TXT", "default._domainkey.example.com.") + if op.Action != ActionAdd { + t.Fatalf("DKIM action = %s, want add", op.Action) + } + data := op.Records[0].Data + if len(data) != 2 { + t.Fatalf("segments = %d, want 2 (255-char split)", len(data)) + } + if len(data[0]) != 255 || strings.Join(data, "") != "v=DKIM1; p="+long { + t.Errorf("bad segmentation: lens %d,%d", len(data[0]), len(data[1])) + } +} + +func TestBuildDNSPlanHostValidationRecordsAreSkipped(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", + txtRec("_acme-challenge", "token1", 14400), + txtRec("_acme-challenge.www.shop", "token2", 14400), + txtRec("_cpanel-dcv-test-record", "token3", 14400))) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + + _, z := singleZonePlan(t, src, dest, nil) + for _, name := range []string{ + "_acme-challenge.example.com.", + "_acme-challenge.www.shop.example.com.", + "_cpanel-dcv-test-record.example.com.", + } { + if op := findOp(t, z, "TXT", name); op.Action != ActionSkip { + t.Errorf("%s action = %s, want skip (host-validation)", name, op.Action) + } + } +} + +func TestBuildDNSPlanTTLRules(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", + cnameRec("cdn", "edge.example.net.", 14400), // > cap + cnameRec("low", "edge.example.net.", 300))) // <= cap + dest := planInventory("destination", "192.0.2.2", + // Same value as source but different TTL: must be skip, not replace. + planZone("example.com", cnameRec("cdn", "edge.example.net.", 60))) + + _, z := singleZonePlan(t, src, dest, nil) + if op := findOp(t, z, "CNAME", "cdn.example.com."); op.Action != ActionSkip { + t.Errorf("TTL-only difference action = %s, want skip", op.Action) + } + op := findOp(t, z, "CNAME", "low.example.com.") + if op.Records[0].TTL != 300 || op.TTLCapped { + t.Errorf("low TTL should pass through uncapped: ttl=%d capped=%v", op.Records[0].TTL, op.TTLCapped) + } + // A new rrset with a high TTL gets capped on write. + src2 := planInventory("source", "192.0.2.1", + planZone("example.com", cnameRec("big", "edge.example.net.", 14400))) + dest2 := planInventory("destination", "192.0.2.2", planZone("example.com")) + _, z2 := singleZonePlan(t, src2, dest2, nil) + op2 := findOp(t, z2, "CNAME", "big.example.com.") + if op2.Records[0].TTL != 3600 || !op2.TTLCapped { + t.Errorf("high TTL should cap at 3600: ttl=%d capped=%v", op2.Records[0].TTL, op2.TTLCapped) + } +} + +func TestBuildDNSPlanCaseAndDotNormalization(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", cnameRec("WWW", "Example.COM.", 14400))) + dest := planInventory("destination", "192.0.2.2", + planZone("example.com", cnameRec("www.example.com.", "example.com.", 14400))) + + // Same rrset spelled differently on the two sides: canonical compare + // must see them as equal (skip), not as an add of a duplicate. + _, z := singleZonePlan(t, src, dest, nil) + if op := findOp(t, z, "CNAME", "www.example.com."); op.Action != ActionSkip { + t.Errorf("case/dot variant action = %s, want skip", op.Action) + } +} + +func TestBuildDNSPlanMXValuesAndDestinationOnly(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", mxRec("example.com.", "example.com.", 0, 14400))) + dest := planInventory("destination", "192.0.2.2", + planZone("example.com", + mxRec("example.com.", "example.com.", 0, 14400), + aRec("dest-only", "198.51.100.9", 300))) + + _, z := singleZonePlan(t, src, dest, nil) + if op := findOp(t, z, "MX", "example.com."); op.Action != ActionSkip { + t.Errorf("equal MX action = %s, want skip", op.Action) + } + // Destination-only rrsets are informational, never deleted. + if len(z.Informational) != 1 || z.Informational[0].Name != "dest-only.example.com." { + t.Errorf("informational = %+v, want the dest-only A rrset", z.Informational) + } + for _, op := range z.Ops { + if op.Action == "delete" { + t.Fatalf("plan generated a delete op: %+v", op) + } + } +} + +func TestBuildDNSPlanMXAddCarriesPreference(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", mxRec("example.com.", "mail.example.com.", 10, 3600))) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + + _, z := singleZonePlan(t, src, dest, nil) + op := findOp(t, z, "MX", "example.com.") + if op.Action != ActionAdd { + t.Fatalf("MX action = %s, want add", op.Action) + } + if len(op.Records[0].Data) != 2 || op.Records[0].Data[0] != "10" || op.Records[0].Data[1] != "mail.example.com." { + t.Errorf("MX data = %v, want [10 mail.example.com.]", op.Records[0].Data) + } +} + +func TestBuildDNSPlanUnknownTypeIsManual(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", + cpanel.DNSRecord{Type: "SRV", Name: "_sip._tcp", TTL: 300, Value: "0 5 5060 sip.example.com."})) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + + _, z := singleZonePlan(t, src, dest, nil) + if op := findOp(t, z, "SRV", "_sip._tcp.example.com."); op.Action != ActionManual { + t.Errorf("SRV action = %s, want manual (unsupported type)", op.Action) + } +} + +func TestBuildDNSPlanZoneMissingOnDestinationIsManual(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", aRec("example.com.", "203.0.113.11", 14400)), + planZone("other.com", aRec("other.com.", "203.0.113.11", 14400))) + dest := planInventory("destination", "192.0.2.2", + planZone("example.com", aRec("example.com.", "203.0.113.12", 14400))) + + p, err := BuildDNSPlan(src, dest, nil, map[string]string{"203.0.113.11": "203.0.113.12"}) + if err != nil { + t.Fatal(err) + } + if len(p.Zones) != 1 || p.Zones[0].Zone != "example.com" { + t.Fatalf("planned zones = %+v, want only example.com", p.Zones) + } + if len(p.ManualZones) != 1 || p.ManualZones[0].Zone != "other.com" { + t.Fatalf("manual zones = %+v, want other.com", p.ManualZones) + } +} + +func TestBuildDNSPlanWildcardOwnerFlowsThrough(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", aRec("*", "203.0.113.11", 3600))) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + + _, z := singleZonePlan(t, src, dest, map[string]string{"203.0.113.11": "203.0.113.12"}) + op := findOp(t, z, "A", "*.example.com.") + if op.Action != ActionAdd || op.Records[0].Data[0] != "203.0.113.12" { + t.Errorf("wildcard op = %+v, want translated add", op) + } +} + +func TestBuildDNSPlanUnavailableDNSSectionFails(t *testing.T) { + src := planInventory("source", "192.0.2.1", planZone("example.com")) + src.DNS.Available = false + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + if _, err := BuildDNSPlan(src, dest, nil, nil); err == nil { + t.Fatal("want error when the DNS section is unavailable") + } +} + +func TestBuildDNSPlanPolicyFindingsAttachedToZone(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", mxRec("example.com.", "example.com.", 0, 14400))) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + pol := &PolicyReport{Findings: []PolicyFinding{ + {ID: "POL-DNS-MX-REMOVED", Section: "dns", Severity: SeverityBlocker, + SourceRef: "zone example.com MX example.com."}, + {ID: "POL-MAILBOX-REMOVED", Section: "mailboxes", Severity: SeverityBlocker}, + }} + + p, err := BuildDNSPlan(src, dest, pol, nil) + if err != nil { + t.Fatal(err) + } + if len(p.Zones[0].PolicyFindings) != 1 || p.Zones[0].PolicyFindings[0] != "POL-DNS-MX-REMOVED [blocker] zone example.com MX example.com." { + t.Errorf("zone policy findings = %+v", p.Zones[0].PolicyFindings) + } + if len(p.NonDNSBlockers) != 1 { + t.Errorf("non-DNS blockers = %+v, want the mailbox one", p.NonDNSBlockers) + } +} + +func TestBuildDNSPlanDeterministicOrdering(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("bbb.com", cnameRec("z", "t.example.", 300), cnameRec("a", "t.example.", 300)), + planZone("aaa.com", cnameRec("m", "t.example.", 300))) + dest := planInventory("destination", "192.0.2.2", + planZone("bbb.com"), planZone("aaa.com")) + + p1, err := BuildDNSPlan(src, dest, nil, nil) + if err != nil { + t.Fatal(err) + } + p2, _ := BuildDNSPlan(src, dest, nil, nil) + if p1.Zones[0].Zone != "aaa.com" || p1.Zones[1].Zone != "bbb.com" { + t.Errorf("zones not sorted: %s, %s", p1.Zones[0].Zone, p1.Zones[1].Zone) + } + ops := p1.Zones[1].Ops + if ops[0].Name != "a.bbb.com." || ops[1].Name != "z.bbb.com." { + t.Errorf("ops not sorted by name: %s, %s", ops[0].Name, ops[1].Name) + } + // Same inputs -> identical plan (summary included). + if p1.Summary != p2.Summary { + t.Errorf("summary not deterministic: %+v vs %+v", p1.Summary, p2.Summary) + } +} + +func TestCanonDNSName(t *testing.T) { + cases := []struct { + name, zone, want string + }{ + {"", "example.com", "example.com."}, + {"@", "example.com", "example.com."}, + {"example.com.", "example.com", "example.com."}, + {"example.com", "example.com", "example.com."}, // bare apex, no dot + {"Example.COM", "example.com", "example.com."}, // bare apex, mixed case + {"WWW", "example.com", "www.example.com."}, + {"www.example.com.", "Example.Com", "www.example.com."}, + {"*", "example.com", "*.example.com."}, + {"mail", "example.com.", "mail.example.com."}, // zone already dotted + {"a.b", "example.com", "a.b.example.com."}, + } + for _, c := range cases { + if got := canonDNSName(c.name, c.zone); got != c.want { + t.Errorf("canonDNSName(%q, %q) = %q, want %q", c.name, c.zone, got, c.want) + } + } +} + +func TestSplitTXTSegmentsUTF8Boundary(t *testing.T) { + // 254 ASCII bytes then a 2-byte rune straddling the 255-byte cut: + // the split must back off to the rune boundary, keeping every + // segment valid UTF-8 (JSON round-trip safe) and rejoinable. + s := strings.Repeat("a", 254) + "\u00e8" + strings.Repeat("b", 10) + segs := splitTXTSegments(s) + if strings.Join(segs, "") != s { + t.Fatal("segments do not rejoin to the source value") + } + for i, seg := range segs { + if len(seg) > 255 { + t.Errorf("segment %d exceeds 255 bytes: %d", i, len(seg)) + } + if !utf8.ValidString(seg) { + t.Errorf("segment %d is invalid UTF-8 - JSON persistence would corrupt it", i) + } + } + if len(segs[0]) != 254 { + t.Errorf("first cut = %d bytes, want 254 (backed off the rune boundary)", len(segs[0])) + } +} + +func TestBuildDNSPlanInvalidUTF8TXTIsManual(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", txtRec("bad", "ok\xff\xfe", 300))) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + + _, z := singleZonePlan(t, src, dest, nil) + if op := findOp(t, z, "TXT", "bad.example.com."); op.Action != ActionManual { + t.Errorf("invalid-UTF-8 TXT action = %s, want manual", op.Action) + } +} + +func TestBuildDNSPlanTXTMultiMatchReasonIsDeterministic(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", + txtRec("example.com.", "v=spf1 ip4:203.0.113.11 ip4:203.0.113.10 ~all", 300))) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + ipMap := map[string]string{"203.0.113.11": "198.51.100.1", "203.0.113.10": "198.51.100.2"} + + _, z1 := singleZonePlan(t, src, dest, ipMap) + op1 := findOp(t, z1, "TXT", "example.com.") + for i := 0; i < 5; i++ { + _, z2 := singleZonePlan(t, src, dest, ipMap) + if op2 := findOp(t, z2, "TXT", "example.com."); op2.Reason != op1.Reason { + t.Fatalf("manual reason not deterministic: %q vs %q", op1.Reason, op2.Reason) + } + } + if !strings.Contains(op1.Reason, "203.0.113.10") || !strings.Contains(op1.Reason, "203.0.113.11") { + t.Errorf("reason should list every matched address: %q", op1.Reason) + } +} + +func TestBuildDNSPlanIPv6SpellingVariantsMatch(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", + cpanel.DNSRecord{Type: "AAAA", Name: "v6", TTL: 300, + Address: "2001:0db8:0000:0000:0000:0000:0000:0001", Value: "2001:0db8::1"})) + dest := planInventory("destination", "192.0.2.2", planZone("example.com")) + + // The map uses the compressed spelling; the record the expanded one. + _, z := singleZonePlan(t, src, dest, map[string]string{"2001:db8::1": "2001:db8::2"}) + op := findOp(t, z, "AAAA", "v6.example.com.") + if op.Action != ActionAdd { + t.Fatalf("AAAA spelling variant action = %s, want add (netip-canonical match)", op.Action) + } + if op.Records[0].Data[0] != "2001:db8::2" { + t.Errorf("translated AAAA = %v, want canonical 2001:db8::2", op.Records[0].Data) + } +} + +func TestBuildDNSPlanZonesJSONStaysArrayWhenAllManual(t *testing.T) { + // Fresh-migration shape: the destination has no zones at all. The + // plan must keep zones=[] (array), not null, for jq/6C consumers. + src := planInventory("source", "192.0.2.1", + planZone("example.com", aRec("example.com.", "203.0.113.11", 300))) + dest := planInventory("destination", "192.0.2.2") + + p, err := BuildDNSPlan(src, dest, nil, nil) + if err != nil { + t.Fatal(err) + } + if p.Zones == nil { + t.Fatal("plan.Zones is nil - must be a non-nil empty slice (JSON [])") + } + if len(p.ManualZones) != 1 { + t.Fatalf("manual zones = %+v", p.ManualZones) + } +} + +func TestBuildDNSPlanSummaryCounts(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", + aRec("new", "203.0.113.11", 300), // add + aRec("ext", "203.0.113.9", 300), // manual (unmapped) + cnameRec("same", "t.example.", 300), // skip + txtRec("_acme-challenge", "tok", 300))) // skip (host-validation) + dest := planInventory("destination", "192.0.2.2", + planZone("example.com", + cnameRec("same", "t.example.", 300), + aRec("only-dest", "198.51.100.9", 300))) + + p, err := BuildDNSPlan(src, dest, nil, map[string]string{"203.0.113.11": "203.0.113.12"}) + if err != nil { + t.Fatal(err) + } + want := PlanSummary{Add: 1, Replace: 0, Manual: 1, Skip: 2, Informational: 1} + if p.Summary != want { + t.Errorf("summary = %+v, want %+v", p.Summary, want) + } +} diff --git a/internal/accountinventory/dnsplan_txt_translated_test.go b/internal/accountinventory/dnsplan_txt_translated_test.go new file mode 100644 index 00000000..1e55e846 --- /dev/null +++ b/internal/accountinventory/dnsplan_txt_translated_test.go @@ -0,0 +1,200 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "strings" + "testing" +) + +// Real-data finding (docs/dev/PR7A_REAL_SMOKE.md): a TXT rrset containing a +// mapped source address must NOT land in manual when the destination +// ALREADY carries exactly the ip-map translation - the state is correct, +// there is nothing to rewrite. Any other destination state (missing, +// partial, different) must keep failing safe into manual. +func TestDNSPlanTXTAlreadyTranslatedOnDestination(t *testing.T) { + const ( + oldIP = "203.0.113.11" + newIP = "203.0.113.12" + ) + spfOld := "v=spf1 +a +mx +ip4:" + oldIP + " ~all" + spfNew := "v=spf1 +a +mx +ip4:" + newIP + " ~all" + + tests := []struct { + name string + ipMap map[string]string + destRecord []cpanel.DNSRecord // TXT rrset on the destination (nil = absent) + wantAction string + }{ + { + name: "destination already carries the translated SPF", + ipMap: map[string]string{oldIP: newIP}, + destRecord: []cpanel.DNSRecord{txtRec("example.com.", spfNew, 300)}, + wantAction: ActionSkip, + }, + { + name: "identity map with identical destination (PR6B scenario C false positive)", + ipMap: map[string]string{oldIP: oldIP}, + destRecord: []cpanel.DNSRecord{txtRec("example.com.", spfOld, 300)}, + wantAction: ActionSkip, + }, + { + name: "destination missing stays manual", + ipMap: map[string]string{oldIP: newIP}, + destRecord: nil, + wantAction: ActionManual, + }, + { + name: "destination with a DIFFERENT value stays manual", + ipMap: map[string]string{oldIP: newIP}, + destRecord: []cpanel.DNSRecord{txtRec("example.com.", "v=spf1 +a ~all", 300)}, + wantAction: ActionManual, + }, + { + name: "destination with stale OLD address stays manual", + ipMap: map[string]string{oldIP: newIP}, + // dest still publishes the old server: the operator must act. + destRecord: []cpanel.DNSRecord{txtRec("example.com.", spfOld, 300)}, + wantAction: ActionManual, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + src := planInventory("source", "192.0.2.1", + planZone("example.com", txtRec("example.com.", spfOld, 300))) + destRecords := tt.destRecord + dest := planInventory("destination", "192.0.2.2", + planZone("example.com", destRecords...)) + + plan, err := BuildDNSPlan(src, dest, nil, tt.ipMap) + if err != nil { + t.Fatal(err) + } + op := findOp(t, plan.Zones[0], "TXT", "example.com.") + if op.Action != tt.wantAction { + t.Fatalf("action = %q (reason %q), want %q", op.Action, op.Reason, tt.wantAction) + } + if tt.wantAction == ActionSkip && !strings.Contains(op.Reason, "translation") { + t.Errorf("skip reason %q should explain the destination already matches the translation", op.Reason) + } + }) + } +} + +// Adversarial review finding: a CYCLIC ip-map (two accounts swapping +// servers in the same batch: A->B, B->A) must never make the substitution +// cancel itself out. With a sequential replace, translate(source) would +// regenerate the ORIGINAL value and match a stale, unmigrated +// destination - a false skip that hides real pending work. Substitution +// must be simultaneous and single-pass over the original string. +func TestDNSPlanTXTCyclicIPMapNeverFalseSkips(t *testing.T) { + const ipA, ipB = "198.51.100.1", "198.51.100.2" + swap := map[string]string{ipA: ipB, ipB: ipA} + spfA := "v=spf1 ip4:" + ipA + " ~all" + spfB := "v=spf1 ip4:" + ipB + " ~all" + + src := planInventory("source", ipA, planZone("example.com", txtRec("example.com.", spfA, 300))) + + // Destination STALE (still the source value): the record was NOT + // migrated - it must stay manual, never skip. + destStale := planInventory("destination", ipB, planZone("example.com", txtRec("example.com.", spfA, 300))) + plan, err := BuildDNSPlan(src, destStale, nil, swap) + if err != nil { + t.Fatal(err) + } + if op := findOp(t, plan.Zones[0], "TXT", "example.com."); op.Action != ActionManual { + t.Errorf("stale destination with cyclic map: action = %q (reason %q), want manual", op.Action, op.Reason) + } + + // Destination correctly translated (single hop A->B): skip is right. + destOK := planInventory("destination", ipB, planZone("example.com", txtRec("example.com.", spfB, 300))) + plan, err = BuildDNSPlan(src, destOK, nil, swap) + if err != nil { + t.Fatal(err) + } + if op := findOp(t, plan.Zones[0], "TXT", "example.com."); op.Action != ActionSkip { + t.Errorf("translated destination with cyclic map: action = %q, want skip", op.Action) + } +} + +// A linear chain in the map (A->B, B->C) must translate each occurrence +// with a SINGLE hop: a source value carrying A matches a destination +// carrying B (correct), never C (double hop). +func TestDNSPlanTXTChainedIPMapSingleHop(t *testing.T) { + chain := map[string]string{"198.51.100.1": "198.51.100.2", "198.51.100.2": "203.0.113.3"} + src := planInventory("source", "198.51.100.1", planZone("example.com", + txtRec("example.com.", "v=spf1 ip4:198.51.100.1 ~all", 300))) + dest := planInventory("destination", "198.51.100.2", planZone("example.com", + txtRec("example.com.", "v=spf1 ip4:198.51.100.2 ~all", 300))) + + plan, err := BuildDNSPlan(src, dest, nil, chain) + if err != nil { + t.Fatal(err) + } + if op := findOp(t, plan.Zones[0], "TXT", "example.com."); op.Action != ActionSkip { + t.Errorf("single-hop translated destination: action = %q (reason %q), want skip", op.Action, op.Reason) + } +} + +// A multi-record TXT rrset (SPF + site verification at the same name) is +// skipped only when EVERY value matches the translated source set. +func TestDNSPlanTXTMultiRecordTranslation(t *testing.T) { + const oldIP, newIP = "203.0.113.11", "203.0.113.12" + src := planInventory("source", "192.0.2.1", planZone("example.com", + txtRec("example.com.", "v=spf1 ip4:"+oldIP+" ~all", 300), + txtRec("example.com.", "site-verification=abc", 300))) + + destFull := planInventory("destination", "192.0.2.2", planZone("example.com", + txtRec("example.com.", "v=spf1 ip4:"+newIP+" ~all", 300), + txtRec("example.com.", "site-verification=abc", 300))) + plan, err := BuildDNSPlan(src, destFull, nil, map[string]string{oldIP: newIP}) + if err != nil { + t.Fatal(err) + } + if op := findOp(t, plan.Zones[0], "TXT", "example.com."); op.Action != ActionSkip { + t.Errorf("full translated rrset: action = %q, want skip", op.Action) + } + + destPartial := planInventory("destination", "192.0.2.2", planZone("example.com", + txtRec("example.com.", "v=spf1 ip4:"+newIP+" ~all", 300))) + plan, err = BuildDNSPlan(src, destPartial, nil, map[string]string{oldIP: newIP}) + if err != nil { + t.Fatal(err) + } + if op := findOp(t, plan.Zones[0], "TXT", "example.com."); op.Action != ActionManual { + t.Errorf("partial rrset: action = %q, want manual (verification TXT is missing)", op.Action) + } +} + +// End-to-end with the checklist: a translated-and-correct SPF must surface +// as an expected difference, not as a blocking UPDATE_SPF action. +func TestChecklistTranslatedSPFIsExpectedNotBlocking(t *testing.T) { + const oldIP, newIP = "203.0.113.11", "203.0.113.12" + src := chkInventory("source", "192.0.2.1", "srcacct") + src.DNS.Zones = []DNSZoneResult{planZone("main.example", + aRec("main.example.", oldIP, 300), + txtRec("main.example.", "v=spf1 +a +mx +ip4:"+oldIP+" ~all", 300))} + dest := chkInventory("destination", "192.0.2.2", "srcacct") + dest.DNS.Zones = []DNSZoneResult{planZone("main.example", + aRec("main.example.", newIP, 300), + txtRec("main.example.", "v=spf1 +a +mx +ip4:"+newIP+" ~all", 300))} + + plan, err := BuildDNSPlan(src, dest, nil, map[string]string{oldIP: newIP}) + if err != nil { + t.Fatal(err) + } + c := BuildChecklist(chkInput(src, dest, &plan, chkApplyReport())) + + if acts := chkActionsOf(c, "dns", MActionUpdateSPF); len(acts) != 0 { + t.Errorf("UPDATE_SPF actions = %d, want 0: the destination SPF is already correct", len(acts)) + } + dns := chkSection(t, c, "dns") + if dns.Status != SectionExpectedDifference { + t.Errorf("dns status = %q, want %q", dns.Status, SectionExpectedDifference) + } + if len(dns.ExpectedDifferences) < 2 { // A rrset + TXT rrset + t.Errorf("dns expected differences = %d, want >= 2 (A and SPF)", len(dns.ExpectedDifferences)) + } +} diff --git a/internal/accountinventory/dnsplan_write.go b/internal/accountinventory/dnsplan_write.go new file mode 100644 index 00000000..01e9e133 --- /dev/null +++ b/internal/accountinventory/dnsplan_write.go @@ -0,0 +1,149 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// WriteDNSPlanJSON writes the machine-readable DNS import plan. +func WriteDNSPlanJSON(path string, p DNSPlan) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal dns plan: %w", err) + } + b = append(b, '\n') + return os.WriteFile(path, b, 0o600) +} + +// WriteDNSPlanMarkdown writes the human-readable plan. Manual items are +// listed first: they are the ones requiring operator work before an +// apply is meaningful. Cells go through mdCell. +func WriteDNSPlanMarkdown(path string, p DNSPlan) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + + fmt.Fprintf(&sb, "# DNS Import Plan\n\n") + fmt.Fprintf(&sb, "- **Source**: %s (sha256 %s)\n", p.SourceFile, p.SourceSHA256) + fmt.Fprintf(&sb, "- **Destination**: %s (sha256 %s)\n", p.DestinationFile, p.DestinationSHA256) + if p.PolicyFile != "" { + fmt.Fprintf(&sb, "- **Policy context**: %s\n", p.PolicyFile) + } + fmt.Fprintf(&sb, "- **Generated**: %s\n", p.GeneratedAt) + fmt.Fprintf(&sb, "- **IP map**: %s\n\n", formatIPMap(p.IPMap)) + fmt.Fprintf(&sb, "**Summary**: %d add, %d replace, %d manual, %d skip, %d informational\n\n", + p.Summary.Add, p.Summary.Replace, p.Summary.Manual, p.Summary.Skip, p.Summary.Informational) + sb.WriteString("The plan never deletes destination records; `manual` items are never applied and have no override.\n\n") + + for _, b := range p.NonDNSBlockers { + fmt.Fprintf(&sb, "> **Non-DNS blocker (context only)**: %s\n", b) + } + if len(p.NonDNSBlockers) > 0 { + sb.WriteString("\n") + } + + if len(p.ManualZones) > 0 { + sb.WriteString("## Zones excluded from the plan\n\n") + sb.WriteString("| Zone | Reason |\n|------|--------|\n") + for _, mz := range p.ManualZones { + fmt.Fprintf(&sb, "| %s | %s |\n", mdCell(mz.Zone, 60), mdCell(mz.Reason, 100)) + } + sb.WriteString("\n") + } + + if len(p.Zones) == 0 { + sb.WriteString("No zones to plan.\n") + return os.WriteFile(path, []byte(sb.String()), 0o600) + } + + for _, z := range p.Zones { + fmt.Fprintf(&sb, "## Zone %s\n\n", z.Zone) + for _, f := range z.PolicyFindings { + fmt.Fprintf(&sb, "> Policy: %s\n", f) + } + if len(z.PolicyFindings) > 0 { + sb.WriteString("\n") + } + + for _, action := range []string{ActionManual, ActionReplace, ActionAdd, ActionSkip} { + rows := opsByAction(z.Ops, action) + if len(rows) == 0 { + continue + } + fmt.Fprintf(&sb, "### %s (%d)\n\n", strings.ToUpper(action), len(rows)) + sb.WriteString("| Type | Name | Desired / Reason | Destination now |\n") + sb.WriteString("|------|------|------------------|------------------|\n") + for _, op := range rows { + detail := op.Reason + if detail == "" { + detail = displayRecords(op) + } + fmt.Fprintf(&sb, "| %s | %s | %s | %s |\n", + mdCell(op.Type, 8), mdCell(op.Name, 60), mdCell(detail, 100), + mdCell(strings.Join(op.DestinationValues, "; "), 70)) + } + sb.WriteString("\n") + } + + if len(z.Informational) > 0 { + fmt.Fprintf(&sb, "### Destination-only rrsets (%d, never deleted)\n\n", len(z.Informational)) + sb.WriteString("| Type | Name | Values |\n|------|------|--------|\n") + for _, info := range z.Informational { + fmt.Fprintf(&sb, "| %s | %s | %s |\n", + mdCell(info.Type, 8), mdCell(info.Name, 60), mdCell(strings.Join(info.Values, "; "), 100)) + } + sb.WriteString("\n") + } + } + + return os.WriteFile(path, []byte(sb.String()), 0o600) +} + +func opsByAction(ops []PlanOp, action string) []PlanOp { + var out []PlanOp + for _, op := range ops { + if op.Action == action { + out = append(out, op) + } + } + return out +} + +func displayRecords(op PlanOp) string { + parts := make([]string, 0, len(op.Records)) + for _, r := range op.Records { + s := strings.Join(r.Data, " ") + if op.TTLCapped { + s = fmt.Sprintf("%s (ttl %d)", s, r.TTL) + } + parts = append(parts, s) + } + return strings.Join(parts, "; ") +} + +func formatIPMap(m map[string]string) string { + if len(m) == 0 { + return "(empty)" + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+m[k]) + } + return strings.Join(parts, ", ") +} diff --git a/internal/accountinventory/dnsplan_write_test.go b/internal/accountinventory/dnsplan_write_test.go new file mode 100644 index 00000000..bd0f28e8 --- /dev/null +++ b/internal/accountinventory/dnsplan_write_test.go @@ -0,0 +1,109 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func buildTestPlan(t *testing.T) DNSPlan { + t.Helper() + src := planInventory("source", "192.0.2.1", + planZone("example.com", + aRec("new", "203.0.113.11", 300), + aRec("ext", "203.0.113.9", 300), + nsRec("example.com.", "ns1.old.example.", 86400), + txtRec("_acme-challenge", "tok", 300))) + dest := planInventory("destination", "192.0.2.2", + planZone("example.com", aRec("only-dest", "198.51.100.9", 300))) + p, err := BuildDNSPlan(src, dest, nil, map[string]string{"203.0.113.11": "203.0.113.12"}) + if err != nil { + t.Fatal(err) + } + p.GeneratedAt = "t" + p.SourceFile, p.SourceSHA256 = "s.json", "aaa" + p.DestinationFile, p.DestinationSHA256 = "d.json", "bbb" + return p +} + +func TestWriteDNSPlanJSONRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "plan.json") + p := buildTestPlan(t) + if err := WriteDNSPlanJSON(path, p); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var got DNSPlan + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if got.Mode != "dns-import-plan" || got.FormatVersion != 1 { + t.Errorf("mode/version = %q/%d", got.Mode, got.FormatVersion) + } + if got.SourceSHA256 != "aaa" || got.DestinationSHA256 != "bbb" { + t.Error("input hashes not persisted") + } + if got.IPMap["203.0.113.11"] != "203.0.113.12" { + t.Error("ip-map not embedded verbatim in the plan") + } + if got.Summary != p.Summary { + t.Errorf("summary lost: %+v vs %+v", got.Summary, p.Summary) + } +} + +func TestWriteDNSPlanMarkdown(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "plan.md") + if err := WriteDNSPlanMarkdown(path, buildTestPlan(t)); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + md := string(b) + + // Manual items must be listed BEFORE the actionable ops. + iManual := strings.Index(md, "MANUAL") + iAdd := strings.Index(md, "ADD") + if iManual < 0 || iAdd < 0 || iManual > iAdd { + t.Errorf("manual section must come before add (manual@%d add@%d)", iManual, iAdd) + } + for _, want := range []string{ + "ext.example.com.", // manual: unmapped address + "203.0.113.9", // named in the reason + "new.example.com.", // add + "203.0.113.12", // translated value + "only-dest.example.com.", // informational dest-only + "203.0.113.11=203.0.113.12", // ip-map echoed for auditability + } { + if !strings.Contains(md, want) { + t.Errorf("markdown missing %q", want) + } + } + if strings.Contains(md, "### DELETE") { + t.Error("markdown contains a DELETE op section - the plan must never generate delete ops") + } +} + +func TestWriteDNSPlanMarkdownEmptyPlan(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "plan.md") + p := DNSPlan{Mode: "dns-import-plan", FormatVersion: 1} + if err := WriteDNSPlanMarkdown(path, p); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(path) + if !strings.Contains(string(b), "No zones") { + t.Error("empty plan should say there is nothing to do") + } +} diff --git a/internal/accountinventory/dnsverify.go b/internal/accountinventory/dnsverify.go new file mode 100644 index 00000000..1d4db4f2 --- /dev/null +++ b/internal/accountinventory/dnsverify.go @@ -0,0 +1,278 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "fmt" + "sort" + "strings" + + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" +) + +// DNS verify (PR 6C). VerifyDNSPlan is fully offline: it consumes a DNS +// import plan (PR 6B) and the live DESTINATION zones (re-fetched by the +// `dns verify` command) and reports, per planned op, whether the zone +// matches the plan. It reuses the plan's own comparison machinery +// (groupRRSets, planValue, valuesEqual, canonDNSName) so verify can never +// disagree with the plan about what "equal" means; values only, TTL is +// never compared (plan rule). Design: docs/dev/PR6C_DNS_VERIFY_DESIGN.md. + +// Verify op statuses. `pending` means the zone still matches the +// plan-time state (nothing happened yet): legitimate before an apply, +// a failure after one, it gates either way, the report counts it +// separately so both readings stay clear. +const ( + VerifyStatusApplied = "applied" // add/replace landed: live equals the desired values + VerifyStatusUnchanged = "unchanged" // checkable skip: live still equals the plan-time state + VerifyStatusPending = "pending" // live still equals the plan-time state (add missing / replace old) + VerifyStatusDrift = "drift" // live matches neither desired nor plan-time state + VerifyStatusManualReview = "manual_review" // manual op: reported for the human, never gates + VerifyStatusNotChecked = "not_checked" // excluded skip (SOA, host-validation): no expectation +) + +// VerifyOpResult is the verdict for one planned op. +type VerifyOpResult struct { + Action string `json:"action"` + Type string `json:"type"` + Name string `json:"name"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + ExpectedValues []string `json:"expected_values,omitempty"` + ObservedValues []string `json:"observed_values,omitempty"` +} + +// VerifyZoneReport is the verdict for one plan zone. +type VerifyZoneReport struct { + Zone string `json:"zone"` + Available bool `json:"available"` + Method string `json:"method,omitempty"` + FetchError string `json:"fetch_error,omitempty"` + Ops []VerifyOpResult `json:"ops"` + // Untracked lists live rrsets of actionable types that appear in + // neither the zone's ops nor its plan-time informational set: they + // postdate the plan. Informational, never gating (additive posture). + Untracked []PlanRRSetInfo `json:"untracked,omitempty"` +} + +type VerifySummary struct { + Applied int `json:"applied"` + Unchanged int `json:"unchanged"` + Pending int `json:"pending"` + Drift int `json:"drift"` + ManualReview int `json:"manual_review"` + NotChecked int `json:"not_checked"` + Untracked int `json:"untracked"` + UnavailableZones int `json:"unavailable_zones"` + ManualZones int `json:"manual_zones"` +} + +type DNSVerifyReport struct { + Mode string `json:"mode"` + FormatVersion int `json:"format_version"` + GeneratedAt string `json:"generated_at,omitempty"` + PlanFile string `json:"plan_file,omitempty"` + PlanSHA256 string `json:"plan_sha256,omitempty"` + Zones []VerifyZoneReport `json:"zones"` + // ManualZones is the plan's list, passed through: the plan computed + // no ops for them, so their migration state is unknown, they gate. + ManualZones []ManualZone `json:"manual_zones,omitempty"` + Summary VerifySummary `json:"summary"` + // Clean is the gate predicate: no pending, no drift, no unavailable + // zone, no manual zone. Manual OPS and untracked rrsets never gate + // (gating on manual ops, NS differs in every real migration, would + // deadlock --fail-on-drift, the exact mistake the 6A v2 redesign + // removed from the plan gate). + Clean bool `json:"clean"` +} + +// VerifyDNSPlan compares the live destination zones against the plan. +// live is keyed by lowercase zone name; a plan zone missing from the map +// is treated as unavailable (fail-safe: cannot verify => not verified). +func VerifyDNSPlan(plan DNSPlan, live map[string]DNSZoneResult) DNSVerifyReport { + rep := DNSVerifyReport{ + Mode: "dns-verify", + FormatVersion: 1, + Zones: []VerifyZoneReport{}, + } + + for _, pz := range plan.Zones { + zr := VerifyZoneReport{Zone: pz.Zone, Ops: []VerifyOpResult{}} + lz, ok := live[strings.ToLower(pz.Zone)] + if !ok || !lz.Available { + zr.Available = false + zr.Method = "unavailable" + zr.FetchError = zoneFetchError(lz, ok) + rep.Summary.UnavailableZones++ + rep.Zones = append(rep.Zones, zr) + continue + } + zr.Available = true + zr.Method = lz.Method + + liveSets := groupRRSets(lz.Records, pz.Zone) + known := map[rrsetKey]bool{} + for _, op := range pz.Ops { + known[rrsetKey{Type: op.Type, Name: op.Name}] = true + res := verifyOp(op, liveSets, pz.Zone) + zr.Ops = append(zr.Ops, res) + switch res.Status { + case VerifyStatusApplied: + rep.Summary.Applied++ + case VerifyStatusUnchanged: + rep.Summary.Unchanged++ + case VerifyStatusPending: + rep.Summary.Pending++ + case VerifyStatusDrift: + rep.Summary.Drift++ + case VerifyStatusManualReview: + rep.Summary.ManualReview++ + case VerifyStatusNotChecked: + rep.Summary.NotChecked++ + } + } + for _, info := range pz.Informational { + known[rrsetKey{Type: info.Type, Name: info.Name}] = true + } + for k, records := range liveSets { + if known[k] || !actionableTypes[k.Type] || isHostValidationName(k.Name) { + continue + } + zr.Untracked = append(zr.Untracked, PlanRRSetInfo{ + Type: k.Type, Name: k.Name, Values: sortedValues(records, pz.Zone)}) + } + sort.Slice(zr.Untracked, func(i, j int) bool { + if zr.Untracked[i].Name != zr.Untracked[j].Name { + return zr.Untracked[i].Name < zr.Untracked[j].Name + } + return zr.Untracked[i].Type < zr.Untracked[j].Type + }) + rep.Summary.Untracked += len(zr.Untracked) + rep.Zones = append(rep.Zones, zr) + } + + rep.ManualZones = append(rep.ManualZones, plan.ManualZones...) + rep.Summary.ManualZones = len(rep.ManualZones) + + rep.Clean = rep.Summary.Pending == 0 && + rep.Summary.Drift == 0 && + rep.Summary.UnavailableZones == 0 && + rep.Summary.ManualZones == 0 + return rep +} + +// zoneFetchError renders why a zone could not be verified. +func zoneFetchError(lz DNSZoneResult, fetched bool) string { + if !fetched { + return "zone was not fetched from the destination" + } + if len(lz.Warnings) > 0 { + return strings.Join(lz.Warnings, "; ") + } + if len(lz.Errors) > 0 { + return strings.Join(lz.Errors, "; ") + } + return "zone unavailable on the destination" +} + +// verifyOp applies the status table of the 6C design to one planned op. +// Every unexpected shape degrades to drift with a reason, fail-safe: a +// malformed or hand-edited plan can never verify clean. +func verifyOp(op PlanOp, liveSets map[rrsetKey][]cpanel.DNSRecord, zone string) VerifyOpResult { + res := VerifyOpResult{Action: op.Action, Type: op.Type, Name: op.Name} + liveRecords, present := liveSets[rrsetKey{Type: op.Type, Name: op.Name}] + var observed []string + if present { + observed = sortedValues(liveRecords, zone) + res.ObservedValues = observed + } + + switch op.Action { + case ActionManual: + res.Status = VerifyStatusManualReview + res.Reason = op.Reason + return res + + case ActionSkip: + if op.Type == "SOA" || isHostValidationName(op.Name) { + res.Status = VerifyStatusNotChecked + return res + } + if len(op.DestinationValues) == 0 { + // A checkable skip always has plan-time destination values by + // construction (equality implies the rrset existed). + res.Status = VerifyStatusDrift + res.Reason = "skip op carries no plan-time destination values, malformed or hand-edited plan" + return res + } + res.ExpectedValues = op.DestinationValues + if present && valuesEqual(observed, op.DestinationValues) { + res.Status = VerifyStatusUnchanged + return res + } + res.Status = VerifyStatusDrift + res.Reason = "live rrset no longer matches the plan-time destination state" + return res + + case ActionAdd, ActionReplace: + desired, err := desiredValues(op) + if err != nil { + res.Status = VerifyStatusDrift + res.Reason = err.Error() + return res + } + res.ExpectedValues = desired + switch { + case present && valuesEqual(observed, desired): + res.Status = VerifyStatusApplied + case op.Action == ActionAdd && !present: + res.Status = VerifyStatusPending + res.Reason = "rrset still missing on the destination (plan-time state)" + case op.Action == ActionReplace && present && valuesEqual(observed, op.DestinationValues): + res.Status = VerifyStatusPending + res.Reason = "rrset still carries the plan-time destination values" + default: + res.Status = VerifyStatusDrift + res.Reason = "live rrset matches neither the desired nor the plan-time state" + } + return res + + default: + res.Status = VerifyStatusDrift + res.Reason = fmt.Sprintf("unknown plan action %q, malformed or hand-edited plan", op.Action) + return res + } +} + +// desiredValues derives the comparable values of an add/replace op from +// its write-shaped records, the exact inverse of translateRecords' +// encoding (A/AAAA/CNAME: single datum; MX: preference + exchange; TXT: +// RFC 1035 segments rejoined by plain concatenation). +func desiredValues(op PlanOp) ([]string, error) { + if len(op.Records) == 0 { + return nil, fmt.Errorf("%s op carries no desired records, malformed or hand-edited plan", op.Action) + } + vals := make([]string, 0, len(op.Records)) + for _, r := range op.Records { + if len(r.Data) == 0 { + return nil, fmt.Errorf("desired %s record has empty data, malformed or hand-edited plan", r.Type) + } + switch r.Type { + case "MX": + if len(r.Data) != 2 { + return nil, fmt.Errorf("desired MX record has %d data fields, want 2 (preference, exchange)", len(r.Data)) + } + vals = append(vals, r.Data[0]+"\x00"+r.Data[1]) + case "TXT": + vals = append(vals, strings.Join(r.Data, "")) + default: // A, AAAA, CNAME, one datum each + if len(r.Data) != 1 { + return nil, fmt.Errorf("desired %s record has %d data fields, want 1", r.Type, len(r.Data)) + } + vals = append(vals, r.Data[0]) + } + } + sort.Strings(vals) + return vals, nil +} diff --git a/internal/accountinventory/dnsverify_test.go b/internal/accountinventory/dnsverify_test.go new file mode 100644 index 00000000..7704de56 --- /dev/null +++ b/internal/accountinventory/dnsverify_test.go @@ -0,0 +1,440 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "reflect" + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" +) + +// The verify fixtures build REAL plans through BuildDNSPlan (same +// helpers as dnsplan_test.go) and then hand the engine live zones in +// various states: the engine must agree with the plan's own idea of +// equality (canonicalization, TXT joining, MX preference) because it +// reuses the same comparison machinery. + +var verifyIPMap = map[string]string{"192.0.2.1": "198.51.100.1"} + +func liveZone(zone string, records ...cpanel.DNSRecord) map[string]DNSZoneResult { + return map[string]DNSZoneResult{ + zone: {Available: true, Zone: zone, Method: "uapi", Records: records}, + } +} + +// findVerifyOp returns the single verify result for (type, canonical name). +func findVerifyOp(t *testing.T, z VerifyZoneReport, typ, name string) VerifyOpResult { + t.Helper() + var found []VerifyOpResult + for _, op := range z.Ops { + if op.Type == typ && op.Name == name { + found = append(found, op) + } + } + if len(found) != 1 { + t.Fatalf("verify ops for %s %s = %d, want exactly 1 (ops: %+v)", typ, name, len(found), z.Ops) + } + return found[0] +} + +func singleZoneVerify(t *testing.T, plan DNSPlan, live map[string]DNSZoneResult) (DNSVerifyReport, VerifyZoneReport) { + t.Helper() + rep := VerifyDNSPlan(plan, live) + if len(rep.Zones) != 1 { + t.Fatalf("report zones = %d, want 1", len(rep.Zones)) + } + return rep, rep.Zones[0] +} + +func TestVerifyDNSPlanEnvelope(t *testing.T) { + rep := VerifyDNSPlan(DNSPlan{Zones: []PlanZone{}}, nil) + if rep.Mode != "dns-verify" { + t.Errorf("mode = %q, want dns-verify", rep.Mode) + } + if rep.FormatVersion != 1 { + t.Errorf("format_version = %d, want 1", rep.FormatVersion) + } + if rep.Zones == nil { + t.Error("zones must be non-nil so the JSON stays array-typed") + } + if !rep.Clean { + t.Error("an empty plan verifies clean") + } +} + +func TestVerifyDNSPlanAddApplied(t *testing.T) { + src := planInventory("source", "s", planZone("example.com", aRec("example.com.", "192.0.2.1", 300))) + dest := planInventory("destination", "d", planZone("example.com")) + plan, _ := singleZonePlan(t, src, dest, verifyIPMap) + + rep, z := singleZoneVerify(t, plan, liveZone("example.com", aRec("example.com.", "198.51.100.1", 300))) + op := findVerifyOp(t, z, "A", "example.com.") + if op.Status != "applied" { + t.Fatalf("status = %q, want applied (op: %+v)", op.Status, op) + } + if op.Action != "add" { + t.Errorf("action = %q, want add", op.Action) + } + if len(op.ExpectedValues) != 1 || op.ExpectedValues[0] != "198.51.100.1" { + t.Errorf("expected_values = %v", op.ExpectedValues) + } + if !rep.Clean || rep.Summary.Applied != 1 { + t.Errorf("clean = %v, applied = %d", rep.Clean, rep.Summary.Applied) + } +} + +func TestVerifyDNSPlanAddPendingWhenStillMissing(t *testing.T) { + src := planInventory("source", "s", planZone("example.com", aRec("example.com.", "192.0.2.1", 300))) + dest := planInventory("destination", "d", planZone("example.com")) + plan, _ := singleZonePlan(t, src, dest, verifyIPMap) + + rep, z := singleZoneVerify(t, plan, liveZone("example.com")) + op := findVerifyOp(t, z, "A", "example.com.") + if op.Status != "pending" { + t.Fatalf("status = %q, want pending", op.Status) + } + if rep.Clean { + t.Error("pending must not verify clean") + } + if rep.Summary.Pending != 1 { + t.Errorf("summary.pending = %d, want 1", rep.Summary.Pending) + } +} + +func TestVerifyDNSPlanAddDriftOnUnexpectedValues(t *testing.T) { + src := planInventory("source", "s", planZone("example.com", aRec("example.com.", "192.0.2.1", 300))) + dest := planInventory("destination", "d", planZone("example.com")) + plan, _ := singleZonePlan(t, src, dest, verifyIPMap) + + rep, z := singleZoneVerify(t, plan, liveZone("example.com", aRec("example.com.", "1.2.3.4", 300))) + op := findVerifyOp(t, z, "A", "example.com.") + if op.Status != "drift" { + t.Fatalf("status = %q, want drift", op.Status) + } + if op.Reason == "" { + t.Error("drift must carry a reason") + } + if len(op.ObservedValues) != 1 || op.ObservedValues[0] != "1.2.3.4" { + t.Errorf("observed_values = %v", op.ObservedValues) + } + if rep.Clean || rep.Summary.Drift != 1 { + t.Errorf("clean = %v, drift = %d", rep.Clean, rep.Summary.Drift) + } +} + +func TestVerifyDNSPlanReplaceStates(t *testing.T) { + src := planInventory("source", "s", planZone("example.com", aRec("example.com.", "192.0.2.1", 300))) + dest := planInventory("destination", "d", planZone("example.com", aRec("example.com.", "5.6.7.8", 300))) + plan, pz := singleZonePlan(t, src, dest, verifyIPMap) + if got := findOp(t, pz, "A", "example.com.").Action; got != ActionReplace { + t.Fatalf("plan action = %q, want replace", got) + } + + cases := []struct { + name string + live cpanel.DNSRecord + wantStatus string + }{ + {"applied", aRec("example.com.", "198.51.100.1", 300), "applied"}, + {"pending on plan-time values", aRec("example.com.", "5.6.7.8", 300), "pending"}, + {"drift on third state", aRec("example.com.", "9.9.9.9", 300), "drift"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, z := singleZoneVerify(t, plan, liveZone("example.com", tc.live)) + op := findVerifyOp(t, z, "A", "example.com.") + if op.Status != tc.wantStatus { + t.Fatalf("status = %q, want %q (op: %+v)", op.Status, tc.wantStatus, op) + } + }) + } +} + +func TestVerifyDNSPlanSkipUnchangedAndDrift(t *testing.T) { + // Destination already equals the translation at plan time -> skip. + src := planInventory("source", "s", planZone("example.com", aRec("example.com.", "192.0.2.1", 300))) + dest := planInventory("destination", "d", planZone("example.com", aRec("example.com.", "198.51.100.1", 300))) + plan, pz := singleZonePlan(t, src, dest, verifyIPMap) + if got := findOp(t, pz, "A", "example.com.").Action; got != ActionSkip { + t.Fatalf("plan action = %q, want skip", got) + } + + rep, z := singleZoneVerify(t, plan, liveZone("example.com", aRec("example.com.", "198.51.100.1", 300))) + if op := findVerifyOp(t, z, "A", "example.com."); op.Status != "unchanged" { + t.Fatalf("status = %q, want unchanged", op.Status) + } + if !rep.Clean || rep.Summary.Unchanged != 1 { + t.Errorf("clean = %v, unchanged = %d", rep.Clean, rep.Summary.Unchanged) + } + + rep, z = singleZoneVerify(t, plan, liveZone("example.com", aRec("example.com.", "1.2.3.4", 300))) + if op := findVerifyOp(t, z, "A", "example.com."); op.Status != "drift" { + t.Fatalf("status = %q, want drift", op.Status) + } + if rep.Clean { + t.Error("skip drift must not verify clean") + } +} + +func TestVerifyDNSPlanSOAAndHostValidationNotChecked(t *testing.T) { + soa := cpanel.DNSRecord{Type: "SOA", Name: "example.com.", TTL: 300, Value: "ns.example.com. root.example.com."} + src := planInventory("source", "s", planZone("example.com", + soa, txtRec("_acme-challenge", "token-old", 300))) + dest := planInventory("destination", "d", planZone("example.com")) + plan, _ := singleZonePlan(t, src, dest, nil) + + rep, z := singleZoneVerify(t, plan, liveZone("example.com")) + if op := findVerifyOp(t, z, "SOA", "example.com."); op.Status != "not_checked" { + t.Errorf("SOA status = %q, want not_checked", op.Status) + } + if op := findVerifyOp(t, z, "TXT", "_acme-challenge.example.com."); op.Status != "not_checked" { + t.Errorf("host-validation status = %q, want not_checked", op.Status) + } + if !rep.Clean || rep.Summary.NotChecked != 2 { + t.Errorf("clean = %v, not_checked = %d, excluded skips must never gate", rep.Clean, rep.Summary.NotChecked) + } +} + +func TestVerifyDNSPlanManualOpReportedNeverGates(t *testing.T) { + // NS values differ -> manual op (the everyday case of a real migration). + src := planInventory("source", "s", planZone("example.com", nsRec("example.com.", "ns1.old.example.", 300))) + dest := planInventory("destination", "d", planZone("example.com", nsRec("example.com.", "ns1.new.example.", 300))) + plan, _ := singleZonePlan(t, src, dest, nil) + + rep, z := singleZoneVerify(t, plan, liveZone("example.com", nsRec("example.com.", "ns1.new.example.", 300))) + op := findVerifyOp(t, z, "NS", "example.com.") + if op.Status != "manual_review" { + t.Fatalf("status = %q, want manual_review", op.Status) + } + if op.Reason == "" { + t.Error("manual_review must carry the plan's refusal reason") + } + if len(op.ObservedValues) == 0 { + t.Error("manual_review must show the live values for the human") + } + if !rep.Clean || rep.Summary.ManualReview != 1 { + t.Errorf("clean = %v, manual_review = %d, manual ops must never gate", rep.Clean, rep.Summary.ManualReview) + } +} + +func TestVerifyDNSPlanManualZonesGate(t *testing.T) { + src := planInventory("source", "s", planZone("example.com", aRec("example.com.", "192.0.2.1", 300))) + dest := planInventory("destination", "d") // zone missing on destination + plan, err := BuildDNSPlan(src, dest, nil, verifyIPMap) + if err != nil { + t.Fatal(err) + } + if len(plan.ManualZones) != 1 { + t.Fatalf("manual zones = %d, want 1", len(plan.ManualZones)) + } + + rep := VerifyDNSPlan(plan, nil) + if rep.Clean { + t.Error("a plan with manual zones must not verify clean: its migration state is unknown") + } + if rep.Summary.ManualZones != 1 { + t.Errorf("summary.manual_zones = %d, want 1", rep.Summary.ManualZones) + } + if len(rep.ManualZones) != 1 || rep.ManualZones[0].Zone != "example.com" { + t.Errorf("manual zones passthrough = %+v", rep.ManualZones) + } +} + +func TestVerifyDNSPlanUnavailableZoneGates(t *testing.T) { + src := planInventory("source", "s", planZone("example.com", aRec("example.com.", "192.0.2.1", 300))) + dest := planInventory("destination", "d", planZone("example.com")) + plan, _ := singleZonePlan(t, src, dest, verifyIPMap) + + t.Run("fetch failed", func(t *testing.T) { + live := map[string]DNSZoneResult{"example.com": { + Available: false, Zone: "example.com", Method: "unavailable", + Warnings: []string{"DNS zone example.com unavailable: boom"}, + }} + rep, z := singleZoneVerify(t, plan, live) + if z.Available { + t.Error("zone must report unavailable") + } + if z.FetchError == "" { + t.Error("unavailable zone must carry a fetch error") + } + if len(z.Ops) != 0 { + t.Errorf("no ops may be evaluated on an unfetched zone, got %d", len(z.Ops)) + } + if rep.Clean || rep.Summary.UnavailableZones != 1 { + t.Errorf("clean = %v, unavailable_zones = %d", rep.Clean, rep.Summary.UnavailableZones) + } + }) + + t.Run("zone absent from live map", func(t *testing.T) { + rep, z := singleZoneVerify(t, plan, map[string]DNSZoneResult{}) + if z.Available || rep.Clean { + t.Errorf("missing live zone must be unavailable and gate (available=%v clean=%v)", z.Available, rep.Clean) + } + }) +} + +func TestVerifyDNSPlanUntrackedRRSets(t *testing.T) { + src := planInventory("source", "s", planZone("example.com", aRec("example.com.", "192.0.2.1", 300))) + // legacy CNAME exists only on destination at plan time -> informational. + dest := planInventory("destination", "d", planZone("example.com", + cnameRec("legacy", "old.example.net.", 300))) + plan, _ := singleZonePlan(t, src, dest, verifyIPMap) + + rep, z := singleZoneVerify(t, plan, liveZone("example.com", + aRec("example.com.", "198.51.100.1", 300), // applied + cnameRec("legacy", "old.example.net.", 300), // plan-time informational -> not untracked + cnameRec("brandnew", "cdn.example.net.", 300), // postdates the plan -> untracked + txtRec("_acme-challenge", "fresh-dcv-token", 300), // host-validation -> never untracked + nsRec("example.com.", "ns1.new.example.", 300), // non-actionable type -> never untracked + )) + if len(z.Untracked) != 1 { + t.Fatalf("untracked = %+v, want exactly the brandnew CNAME", z.Untracked) + } + u := z.Untracked[0] + if u.Type != "CNAME" || u.Name != "brandnew.example.com." { + t.Errorf("untracked = %+v", u) + } + if !rep.Clean { + t.Error("untracked rrsets are informational and must not gate") + } + if rep.Summary.Untracked != 1 { + t.Errorf("summary.untracked = %d, want 1", rep.Summary.Untracked) + } +} + +func TestVerifyDNSPlanTXTSegmentsRoundTrip(t *testing.T) { + // DKIM-length TXT: the plan stores split RFC1035 segments, the live + // zone (collector-joined) carries one string, they must compare equal. + long := "v=DKIM1; k=rsa; p=" + strings.Repeat("A", 500) + src := planInventory("source", "s", planZone("example.com", + txtRec("default._domainkey", long, 300))) + dest := planInventory("destination", "d", planZone("example.com")) + plan, pz := singleZonePlan(t, src, dest, nil) + op := findOp(t, pz, "TXT", "default._domainkey.example.com.") + if len(op.Records) != 1 || len(op.Records[0].Data) < 3 { + t.Fatalf("fixture must produce a segmented TXT record, got %+v", op.Records) + } + + _, z := singleZoneVerify(t, plan, liveZone("example.com", + txtRec("default._domainkey", long, 300))) + if got := findVerifyOp(t, z, "TXT", "default._domainkey.example.com."); got.Status != "applied" { + t.Fatalf("status = %q, want applied (joined segments must equal the live value)", got.Status) + } +} + +func TestVerifyDNSPlanMXPreferenceCounts(t *testing.T) { + src := planInventory("source", "s", planZone("example.com", + mxRec("example.com.", "mail.example.com.", 10, 300))) + dest := planInventory("destination", "d", planZone("example.com")) + plan, _ := singleZonePlan(t, src, dest, nil) + + _, z := singleZoneVerify(t, plan, liveZone("example.com", + mxRec("example.com.", "mail.example.com.", 10, 300))) + if op := findVerifyOp(t, z, "MX", "example.com."); op.Status != "applied" { + t.Fatalf("same exchange+preference: status = %q, want applied", op.Status) + } + + _, z = singleZoneVerify(t, plan, liveZone("example.com", + mxRec("example.com.", "mail.example.com.", 20, 300))) + if op := findVerifyOp(t, z, "MX", "example.com."); op.Status != "drift" { + t.Fatalf("changed preference: status = %q, want drift", op.Status) + } +} + +func TestVerifyDNSPlanLiveNameCanonicalization(t *testing.T) { + // Plan from a relative source name; live zone answers with an + // absolute UPPERCASE owner, the same rrset, canonically. + src := planInventory("source", "s", planZone("example.com", aRec("www", "192.0.2.1", 300))) + dest := planInventory("destination", "d", planZone("example.com")) + plan, _ := singleZonePlan(t, src, dest, verifyIPMap) + + _, z := singleZoneVerify(t, plan, liveZone("example.com", + aRec("WWW.EXAMPLE.COM.", "198.51.100.1", 300))) + if op := findVerifyOp(t, z, "A", "www.example.com."); op.Status != "applied" { + t.Fatalf("status = %q, want applied (canonicalization must match)", op.Status) + } +} + +func TestVerifyDNSPlanTTLNeverCompared(t *testing.T) { + src := planInventory("source", "s", planZone("example.com", aRec("example.com.", "192.0.2.1", 14400))) + dest := planInventory("destination", "d", planZone("example.com")) + plan, _ := singleZonePlan(t, src, dest, verifyIPMap) + + // Live TTL differs from the plan's capped write TTL: still applied. + _, z := singleZoneVerify(t, plan, liveZone("example.com", + aRec("example.com.", "198.51.100.1", 86400))) + if op := findVerifyOp(t, z, "A", "example.com."); op.Status != "applied" { + t.Fatalf("status = %q, want applied (TTL is never compared)", op.Status) + } +} + +func TestVerifyDNSPlanMalformedOpsFailSafe(t *testing.T) { + mk := func(op PlanOp) DNSPlan { + return DNSPlan{Mode: "dns-import-plan", FormatVersion: 1, + Zones: []PlanZone{{Zone: "example.com", Ops: []PlanOp{op}}}} + } + cases := []struct { + name string + op PlanOp + }{ + {"add without records", PlanOp{Action: "add", Type: "A", Name: "x.example.com."}}, + {"mx with wrong arity", PlanOp{Action: "add", Type: "MX", Name: "example.com.", + Records: []PlanRecord{{Name: "example.com.", Type: "MX", TTL: 300, Data: []string{"10"}}}}}, + {"record with empty data", PlanOp{Action: "add", Type: "A", Name: "x.example.com.", + Records: []PlanRecord{{Name: "x.example.com.", Type: "A", TTL: 300}}}}, + {"checkable skip without destination values", PlanOp{Action: "skip", Type: "A", Name: "x.example.com."}}, + {"replace without destination values", PlanOp{Action: "replace", Type: "A", Name: "x.example.com.", + Records: []PlanRecord{{Name: "x.example.com.", Type: "A", TTL: 300, Data: []string{"1.2.3.4"}}}}}, + {"unknown action", PlanOp{Action: "delete", Type: "A", Name: "x.example.com.", + Records: []PlanRecord{{Name: "x.example.com.", Type: "A", TTL: 300, Data: []string{"1.2.3.4"}}}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rep, z := singleZoneVerify(t, mk(tc.op), liveZone("example.com")) + op := findVerifyOp(t, z, tc.op.Type, tc.op.Name) + if op.Status != "drift" { + t.Fatalf("status = %q, want drift (malformed plans must fail safe)", op.Status) + } + if op.Reason == "" { + t.Error("fail-safe drift must explain itself") + } + if rep.Clean { + t.Error("malformed plan must not verify clean") + } + }) + } +} + +func TestVerifyDNSPlanSummaryAndDeterminism(t *testing.T) { + src := planInventory("source", "s", planZone("example.com", + aRec("example.com.", "192.0.2.1", 300), // add -> applied + cnameRec("www", "example.com.", 300), // add -> pending + nsRec("example.com.", "ns1.old.example.", 300), // manual + )) + dest := planInventory("destination", "d", planZone("example.com", + nsRec("example.com.", "ns1.new.example.", 300))) + plan, _ := singleZonePlan(t, src, dest, verifyIPMap) + + live := liveZone("example.com", + aRec("example.com.", "198.51.100.1", 300), + nsRec("example.com.", "ns1.new.example.", 300)) + + rep1 := VerifyDNSPlan(plan, live) + rep2 := VerifyDNSPlan(plan, live) + if len(rep1.Zones) != 1 || len(rep1.Zones[0].Ops) != len(plan.Zones[0].Ops) { + t.Fatalf("every plan op must appear in the report: %+v", rep1.Zones) + } + if !reflect.DeepEqual(rep1, rep2) { + t.Fatal("VerifyDNSPlan output is not deterministic") + } + s := rep1.Summary + if s.Applied != 1 || s.Pending != 1 || s.ManualReview != 1 || s.Drift != 0 { + t.Errorf("summary = %+v", s) + } + if rep1.Clean { + t.Error("pending op must gate") + } +} diff --git a/internal/accountinventory/dnsverify_write.go b/internal/accountinventory/dnsverify_write.go new file mode 100644 index 00000000..bba856f1 --- /dev/null +++ b/internal/accountinventory/dnsverify_write.go @@ -0,0 +1,122 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// WriteDNSVerifyJSON writes the machine-readable verify report. +func WriteDNSVerifyJSON(path string, r DNSVerifyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + b, err := json.MarshalIndent(r, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal dns verify report: %w", err) + } + b = append(b, '\n') + return os.WriteFile(path, b, 0o600) +} + +// verifyStatusOrder renders the statuses that demand operator attention +// first: drift, then pending, then the informational rest. +var verifyStatusOrder = []string{ + VerifyStatusDrift, + VerifyStatusPending, + VerifyStatusManualReview, + VerifyStatusApplied, + VerifyStatusUnchanged, + VerifyStatusNotChecked, +} + +// WriteDNSVerifyMarkdown writes the human-readable verify report. Cells +// go through mdCell (long TXT/DKIM values are previewed, never re-exposed +// in full, the checklist rule). +func WriteDNSVerifyMarkdown(path string, r DNSVerifyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + + sb.WriteString("# DNS Verify Report\n\n") + fmt.Fprintf(&sb, "- **Plan**: %s (sha256 %s)\n", r.PlanFile, r.PlanSHA256) + fmt.Fprintf(&sb, "- **Generated**: %s\n", r.GeneratedAt) + verdict := "CLEAN" + if !r.Clean { + verdict = "NOT CLEAN" + } + fmt.Fprintf(&sb, "- **Verdict**: %s\n\n", verdict) + fmt.Fprintf(&sb, "**Summary**: %d applied, %d unchanged, %d pending, %d drift, %d manual review, %d not checked, %d untracked; %d unavailable zone(s), %d manual zone(s)\n\n", + r.Summary.Applied, r.Summary.Unchanged, r.Summary.Pending, r.Summary.Drift, + r.Summary.ManualReview, r.Summary.NotChecked, r.Summary.Untracked, + r.Summary.UnavailableZones, r.Summary.ManualZones) + sb.WriteString("The verdict gates on pending, drift, unavailable zones and manual zones; manual ops and untracked rrsets are reported for review only.\n\n") + + if len(r.ManualZones) > 0 { + sb.WriteString("## Zones excluded from the plan (each one keeps the verdict NOT CLEAN, re-run the pipeline)\n\n") + sb.WriteString("| Zone | Reason |\n|------|--------|\n") + for _, mz := range r.ManualZones { + fmt.Fprintf(&sb, "| %s | %s |\n", mdCell(mz.Zone, 60), mdCell(mz.Reason, 100)) + } + sb.WriteString("\n") + } + + if len(r.Zones) == 0 { + sb.WriteString("No zones verified.\n") + return os.WriteFile(path, []byte(sb.String()), 0o600) + } + + for _, z := range r.Zones { + if !z.Available { + fmt.Fprintf(&sb, "## Zone %s, UNAVAILABLE\n\n", z.Zone) + fmt.Fprintf(&sb, "> %s\n\n", mdCell(z.FetchError, 200)) + continue + } + fmt.Fprintf(&sb, "## Zone %s (fetched via %s)\n\n", z.Zone, z.Method) + + for _, status := range verifyStatusOrder { + rows := opsByStatus(z.Ops, status) + if len(rows) == 0 { + continue + } + fmt.Fprintf(&sb, "### %s (%d)\n\n", strings.ToUpper(strings.ReplaceAll(status, "_", " ")), len(rows)) + sb.WriteString("| Type | Name | Reason | Expected | Observed |\n") + sb.WriteString("|------|------|--------|----------|----------|\n") + for _, op := range rows { + fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s |\n", + mdCell(op.Type, 8), mdCell(op.Name, 60), mdCell(op.Reason, 100), + mdCell(strings.Join(op.ExpectedValues, "; "), 70), + mdCell(strings.Join(op.ObservedValues, "; "), 70)) + } + sb.WriteString("\n") + } + + if len(z.Untracked) > 0 { + fmt.Fprintf(&sb, "### Untracked live rrsets (%d, postdate the plan, review only)\n\n", len(z.Untracked)) + sb.WriteString("| Type | Name | Values |\n|------|------|--------|\n") + for _, u := range z.Untracked { + fmt.Fprintf(&sb, "| %s | %s | %s |\n", + mdCell(u.Type, 8), mdCell(u.Name, 60), mdCell(strings.Join(u.Values, "; "), 100)) + } + sb.WriteString("\n") + } + } + + return os.WriteFile(path, []byte(sb.String()), 0o600) +} + +func opsByStatus(ops []VerifyOpResult, status string) []VerifyOpResult { + var out []VerifyOpResult + for _, op := range ops { + if op.Status == status { + out = append(out, op) + } + } + return out +} diff --git a/internal/accountinventory/dnsverify_write_test.go b/internal/accountinventory/dnsverify_write_test.go new file mode 100644 index 00000000..ced79572 --- /dev/null +++ b/internal/accountinventory/dnsverify_write_test.go @@ -0,0 +1,181 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// buildTestVerifyReport exercises the real plan->verify path so the writer +// fixtures stay honest: one applied add, one manual NS, one pending +// CNAME, one drifted skip, plus an untracked live rrset and a manual zone. +func buildTestVerifyReport(t *testing.T) DNSVerifyReport { + t.Helper() + src := planInventory("source", "1.2.3.4", + planZone("example.com", + aRec("new", "192.0.2.1", 300), + cnameRec("www", "example.com.", 300), + aRec("same", "192.0.2.1", 300), + nsRec("example.com.", "ns1.old.example.", 86400)), + planZone("missing.example", + aRec("missing.example.", "192.0.2.1", 300))) + dest := planInventory("destination", "5.6.7.8", + planZone("example.com", aRec("same", "198.51.100.1", 300))) + p, err := BuildDNSPlan(src, dest, nil, map[string]string{"192.0.2.1": "198.51.100.1"}) + if err != nil { + t.Fatal(err) + } + + rep := VerifyDNSPlan(p, liveZone("example.com", + aRec("new", "198.51.100.1", 300), // add -> applied + aRec("same", "5.5.5.5", 300), // skip -> drift + nsRec("example.com.", "ns1.new.example.", 86400), // manual -> manual_review + txtRec("postplan", "added later", 300), // untracked + )) + rep.GeneratedAt = "t" + rep.PlanFile, rep.PlanSHA256 = "dns_import_plan.json", "ccc" + return rep +} + +func TestWriteDNSVerifyJSONRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "verify.json") + rep := buildTestVerifyReport(t) + if err := WriteDNSVerifyJSON(path, rep); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var got DNSVerifyReport + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if got.Mode != "dns-verify" || got.FormatVersion != 1 { + t.Errorf("mode/version = %q/%d", got.Mode, got.FormatVersion) + } + if got.PlanSHA256 != "ccc" { + t.Error("plan sha256 not persisted") + } + if got.Summary != rep.Summary { + t.Errorf("summary lost: %+v vs %+v", got.Summary, rep.Summary) + } + if got.Clean { + t.Error("fixture report must not be clean (drift + pending + manual zone)") + } + if info, err := os.Stat(path); err != nil { + t.Fatal(err) + } else if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("json file mode = %o, want 600", perm) + } +} + +func TestWriteDNSVerifyMarkdown(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "verify.md") + if err := WriteDNSVerifyMarkdown(path, buildTestVerifyReport(t)); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + md := string(b) + + if !strings.Contains(md, "NOT CLEAN") { + t.Error("markdown must state the verdict") + } + // Drift demands operator attention first. + iDrift := strings.Index(md, "DRIFT") + iApplied := strings.Index(md, "APPLIED") + if iDrift < 0 || iApplied < 0 || iDrift > iApplied { + t.Errorf("drift section must come before applied (drift@%d applied@%d)", iDrift, iApplied) + } + for _, want := range []string{ + "same.example.com.", // drifted skip + "5.5.5.5", // observed drift value + "198.51.100.1", // expected value + "missing.example", // manual zone + "postplan", // untracked rrset + "manual zone", // gate explanation mentions manual zones + } { + if !strings.Contains(md, want) { + t.Errorf("markdown missing %q", want) + } + } +} + +func TestWriteDNSVerifyMarkdownCleanAndEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "verify.md") + rep := VerifyDNSPlan(DNSPlan{Zones: []PlanZone{}}, nil) + if err := WriteDNSVerifyMarkdown(path, rep); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(path) + md := string(b) + if !strings.Contains(md, "CLEAN") || strings.Contains(md, "NOT CLEAN") { + t.Errorf("empty plan verifies clean, markdown says otherwise:\n%s", md) + } + if !strings.Contains(md, "No zones") { + t.Error("empty report should say there is nothing verified") + } +} + +// The markdown is an operator report: long TXT/DKIM material must never be +// re-exposed in full (mdCell previews only), checklist rule, same here. +func TestWriteDNSVerifyMarkdownDoesNotExposeLongTXT(t *testing.T) { + longDKIM := "v=DKIM1; k=rsa; p=" + strings.Repeat("A", 400) + src := planInventory("source", "1.2.3.4", planZone("example.com", + txtRec("default._domainkey", longDKIM, 300))) + dest := planInventory("destination", "5.6.7.8", planZone("example.com")) + p, err := BuildDNSPlan(src, dest, nil, nil) + if err != nil { + t.Fatal(err) + } + rep := VerifyDNSPlan(p, liveZone("example.com", txtRec("default._domainkey", longDKIM, 300))) + + dir := t.TempDir() + path := filepath.Join(dir, "verify.md") + if err := WriteDNSVerifyMarkdown(path, rep); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(path) + if strings.Contains(string(b), longDKIM) { + t.Error("markdown exposes the full DKIM value, cells must go through mdCell") + } +} + +// TestDNSVerifyMarkdownGolden pins the full operator report. Refresh with: +// +// UPDATE_GOLDEN=1 go test ./internal/accountinventory/ -run TestDNSVerifyMarkdownGolden +func TestDNSVerifyMarkdownGolden(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "verify.md") + if err := WriteDNSVerifyMarkdown(path, buildTestVerifyReport(t)); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + goldenPath := filepath.Join("..", "testdata", "dns_verify_report.md.golden") + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile(goldenPath, got, 0o644); err != nil { + t.Fatalf("update golden %s: %v", goldenPath, err) + } + } + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden (run once with UPDATE_GOLDEN=1 to create): %v", err) + } + if string(got) != string(want) { + t.Errorf("verify markdown differs from golden.\nGOT:\n%s\nWANT:\n%s", got, want) + } +} diff --git a/internal/accountinventory/emailapply.go b/internal/accountinventory/emailapply.go new file mode 100644 index 00000000..a469ea8d --- /dev/null +++ b/internal/accountinventory/emailapply.go @@ -0,0 +1,658 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// Email apply engine (PR 2B-1), the OFFLINE half of `email apply`: op +// evaluation against a fresh destination re-list (the per-op freshness +// guard, the email analogue of the DNS serial), the backup/report +// artifact types with their bidirectional pairing, and the report-driven +// rollback computation. Everything here is pure data logic: the SSH +// orchestration lives in the `email apply` command, the write primitives +// in internal/cpanel/email_apply.go (the only allowlisted verb files). + +// Apply op statuses. +const ( + EmailOpApplied = "applied" // written and verified present after the write + EmailOpAlready = "already_present" // the op's outcome was already on the destination + EmailOpRefused = "refused_precondition" + EmailOpFailed = "failed" + EmailOpSkipped = "skipped" // plan action skip: nothing to do + EmailOpManual = "manual" // plan action manual: terminal, never applied + EmailOpPlanned = "planned" // dry-run only: would write +) + +// Apply-time decisions for an actionable (create/set) op. +const ( + EmailDecisionWrite = "write" + EmailDecisionAlready = "already_present" + EmailDecisionRefused = "refused_precondition" +) + +// EmailLiveState is a fresh re-list of the sections the plan touches on +// the DESTINATION, in normalized inventory shape. A section (or a +// domain's forwarder list) that failed to list carries its error so every +// op depending on it refuses fail-closed instead of guessing. +type EmailLiveState struct { + // ForwardersByDomain holds the fresh forwarder list per touched + // domain (normalized entries; Source is local@domain). + ForwardersByDomain map[string][]NormForwarderEntry + // ForwarderListErrors records domains whose re-list failed. + ForwarderListErrors map[string]string + // Defaults is the fresh default-address list (all domains, one call). + Defaults []DefaultAddressEntry + // DefaultsListed is true when the default-address re-list succeeded; + // DefaultsError carries the failure otherwise. + DefaultsListed bool + DefaultsError string + // AutorespondersByDomain holds the fresh autoresponder list per touched + // domain (PR 2B-2), with bodies fetched per address; an entry whose + // per-address body read failed carries BodyCollected=false and every op + // depending on it refuses fail-closed. + AutorespondersByDomain map[string][]NormAutoresponderEntry + // AutoresponderListErrors records domains whose re-list failed. + AutoresponderListErrors map[string]string + // FiltersByAccount holds the fresh filter list per account scope + // ("" = account-level). Each entry's RulesCollected is true when + // get_filter succeeded for it (PR 2B-3). + FiltersByAccount map[string][]NormEmailFilterEntry + // FilterListErrors records account scopes whose re-list failed. + FilterListErrors map[string]string + // RoutingEntries holds the fresh routing list (all domains). + RoutingEntries []EmailRoutingEntry + // RoutingListed is true when the routing re-list succeeded. + RoutingListed bool + RoutingError string +} + +// destForwardTargets returns the canonical target set for one source +// address in the live state. +func (l EmailLiveState) destForwardTargets(domain, address string) []string { + var out []string + for _, f := range l.ForwardersByDomain[domain] { + if canonEmailAddr(f.Source) == canonEmailAddr(address) { + out = append(out, canonEmailAddr(f.Destination)) + } + } + sort.Strings(out) + return out +} + +// forwardPairPresent reports whether the exact pair is live. +func (l EmailLiveState) forwardPairPresent(domain, address, target string) bool { + for _, f := range l.ForwardersByDomain[domain] { + if canonEmailAddr(f.Source) == canonEmailAddr(address) && + canonEmailAddr(f.Destination) == canonEmailAddr(target) { + return true + } + } + return false +} + +// autoresponderFor returns the live autoresponder for one address. +func (l EmailLiveState) autoresponderFor(domain, address string) (NormAutoresponderEntry, bool) { + for _, a := range l.AutorespondersByDomain[domain] { + if canonEmailAddr(a.Email) == canonEmailAddr(address) { + return a, true + } + } + return NormAutoresponderEntry{}, false +} + +// autoresponderContentEquivalent compares a plan payload against a live +// entry using the same field set and body normalization as the plan's +// autorespondersEquivalent (verify can never disagree with the plan). +func autoresponderContentEquivalent(c *EmailAutoresponderContent, e NormAutoresponderEntry) bool { + if c == nil || !e.BodyCollected { + return false + } + return autorespondersEquivalent(NormAutoresponderEntry{ + Subject: c.Subject, From: c.From, Body: c.Body, + IsHTML: c.IsHTML, Interval: c.Interval, Start: c.Start, Stop: c.Stop, + Charset: c.Charset, BodyCollected: true, + }, e) +} + +// splitFilterKey inverts filterKey: "account/filtername" -> account, filtername. +// "(account-level)/name" -> "", "name". +func splitFilterKey(key string) (account, filtername string) { + parts := strings.SplitN(key, "/", 2) + if len(parts) != 2 { + return "", key + } + account = parts[0] + if account == "(account-level)" { + account = "" + } + return account, parts[1] +} + +// filterFor returns the live filter entry for an account+name key. +func (l EmailLiveState) filterFor(account, filtername string) (NormEmailFilterEntry, bool) { + for _, f := range l.FiltersByAccount[account] { + if f.FilterName == filtername { + return f, true + } + } + return NormEmailFilterEntry{}, false +} + +// filterContentEquivalent compares a plan payload against a live entry +// using the same field comparisons as filtersEquivalent. +func filterContentEquivalent(c *EmailFilterContent, e NormEmailFilterEntry) bool { + if c == nil || !e.RulesCollected { + return false + } + return filtersEquivalent(NormEmailFilterEntry{ + Rules: c.Rules, + Actions: c.Actions, + RulesCollected: true, + }, e) +} + +// routingFor returns the live routing value for a domain. +func (l EmailLiveState) routingFor(domain string) (string, bool) { + for _, r := range l.RoutingEntries { + if strings.EqualFold(strings.TrimSpace(r.Domain), strings.TrimSpace(domain)) { + return r.Routing, true + } + } + return "", false +} + +// defaultFor returns the live default address for a domain. +func (l EmailLiveState) defaultFor(domain string) (string, bool) { + for _, d := range l.Defaults { + if strings.EqualFold(strings.TrimSpace(d.Domain), strings.TrimSpace(domain)) { + return d.DefaultAddress, true + } + } + return "", false +} + +// EmailOutcomePresent reports whether an actionable op's OUTCOME is +// observably present in the live state, the check that makes re-running +// a partially applied plan converge without duplicates, and the +// unconditional per-op verify-after predicate. +func EmailOutcomePresent(op EmailPlanOp, live EmailLiveState, destUser string) bool { + switch { + case op.Section == EmailSectionForwarders && op.Action == EmailActionCreate: + return live.forwardPairPresent(op.Domain, op.Key, op.Forward) + case op.Section == EmailSectionDefaultAddress && op.Action == EmailActionSet: + cur, ok := live.defaultFor(op.Domain) + if !ok { + return false + } + // Class-aware equality: a :fail: value round-trips with a + // locale-dependent tail, so exact comparison would false-negative. + return defaultsEquivalent(op.Value, cur, destUser, destUser) + case op.Section == EmailSectionAutoresponders && op.Action == EmailActionCreate: + e, ok := live.autoresponderFor(op.Domain, op.Key) + return ok && autoresponderContentEquivalent(op.Autoresponder, e) + case op.Section == EmailSectionFilters && op.Action == EmailActionCreate: + account, filtername := splitFilterKey(op.Key) + e, ok := live.filterFor(account, filtername) + return ok && filterContentEquivalent(op.Filter, e) + case op.Section == EmailSectionRouting && op.Action == EmailActionSet: + cur, ok := live.routingFor(op.Domain) + return ok && cur == op.Value + } + return false +} + +// EvaluateEmailOp applies the per-op freshness guard to one actionable +// op against the fresh live state, per the 2B design order: +// 1. outcome already present -> already_present (convergence); +// 2. the plan-time precondition still holds -> write; +// 3. anything else -> refused_precondition, fail-closed, continue. +func EvaluateEmailOp(op EmailPlanOp, live EmailLiveState, destUser string) (decision, reason string) { + switch { + case op.Section == EmailSectionForwarders && op.Action == EmailActionCreate: + if msg, failed := live.ForwarderListErrors[op.Domain]; failed { + return EmailDecisionRefused, fmt.Sprintf("fresh forwarder re-list failed for %s: %s", op.Domain, msg) + } + if live.forwardPairPresent(op.Domain, op.Key, op.Forward) { + return EmailDecisionAlready, "" + } + want := make([]string, 0, len(op.PlanTimeDestForwards)) + for _, t := range op.PlanTimeDestForwards { + want = append(want, canonEmailAddr(t)) + } + sort.Strings(want) + got := live.destForwardTargets(op.Domain, op.Key) + if stringSlicesEqual(want, got) { + return EmailDecisionWrite, "" + } + return EmailDecisionRefused, fmt.Sprintf( + "destination forwarders for %s changed since the plan (plan-time %v, now %v), re-plan and review", + op.Key, want, got) + + case op.Section == EmailSectionDefaultAddress && op.Action == EmailActionSet: + if !live.DefaultsListed { + return EmailDecisionRefused, "fresh default-address re-list failed: " + live.DefaultsError + } + cur, ok := live.defaultFor(op.Domain) + if !ok { + return EmailDecisionRefused, fmt.Sprintf("domain %s no longer appears in the destination default-address list", op.Domain) + } + if defaultsEquivalent(op.Value, cur, destUser, destUser) { + return EmailDecisionAlready, "" + } + if defaultsEquivalent(op.DestinationValue, cur, destUser, destUser) { + return EmailDecisionWrite, "" + } + return EmailDecisionRefused, fmt.Sprintf( + "destination default address changed since the plan (plan-time %q, now %q), re-plan and review", + op.DestinationValue, cur) + + case op.Section == EmailSectionAutoresponders && op.Action == EmailActionCreate: + if op.Autoresponder == nil { + return EmailDecisionRefused, fmt.Sprintf("autoresponder create op for %s carries no content payload, malformed or hand-edited plan", op.Key) + } + if msg, failed := live.AutoresponderListErrors[op.Domain]; failed { + return EmailDecisionRefused, fmt.Sprintf("fresh autoresponder re-list failed for %s: %s", op.Domain, msg) + } + e, present := live.autoresponderFor(op.Domain, op.Key) + if !present { + // The plan-time precondition of an autoresponder create is + // "address empty" (a differing dest is terminal manual at plan + // time), still empty means it still holds. + return EmailDecisionWrite, "" + } + if !e.BodyCollected { + return EmailDecisionRefused, fmt.Sprintf( + "an autoresponder exists on %s but its body could not be read, cannot prove equality, refusing fail-closed", op.Key) + } + if autoresponderContentEquivalent(op.Autoresponder, e) { + return EmailDecisionAlready, "" + } + return EmailDecisionRefused, fmt.Sprintf( + "an autoresponder with different content appeared on %s since the plan, the writer never overwrites (the add call would destroy it); re-plan and review", op.Key) + + case op.Section == EmailSectionFilters && op.Action == EmailActionCreate: + if op.Filter == nil { + return EmailDecisionRefused, fmt.Sprintf("filter create op for %s carries no content payload, malformed or hand-edited plan", op.Key) + } + account, filtername := splitFilterKey(op.Key) + if msg, failed := live.FilterListErrors[account]; failed { + return EmailDecisionRefused, fmt.Sprintf("fresh filter re-list failed for account %q: %s", account, msg) + } + e, present := live.filterFor(account, filtername) + if !present { + return EmailDecisionWrite, "" + } + if !e.RulesCollected { + return EmailDecisionRefused, fmt.Sprintf( + "a filter %q exists on the destination but its rules could not be read, cannot prove equality, refusing fail-closed", filtername) + } + if filterContentEquivalent(op.Filter, e) { + return EmailDecisionAlready, "" + } + return EmailDecisionRefused, fmt.Sprintf( + "a filter %q with different content appeared on the destination since the plan, the write call upserts (never-overwrite); re-plan and review", filtername) + + case op.Section == EmailSectionRouting && op.Action == EmailActionSet: + if !live.RoutingListed { + return EmailDecisionRefused, "fresh routing re-list failed: " + live.RoutingError + } + cur, ok := live.routingFor(op.Domain) + if !ok { + return EmailDecisionRefused, fmt.Sprintf("domain %s no longer appears in the routing list", op.Domain) + } + if cur == op.Value { + return EmailDecisionAlready, "" + } + if cur == op.DestinationValue { + return EmailDecisionWrite, "" + } + return EmailDecisionRefused, fmt.Sprintf( + "routing for %s changed since the plan (plan-time %q, now %q), re-plan and review", + op.Domain, op.DestinationValue, cur) + } + return EmailDecisionRefused, fmt.Sprintf("op %s/%s is not actionable (action %q)", op.Section, op.Key, op.Action) +} + +// AutoresponderMatchesContent is the exported content-equality check the +// rollback pre-check uses: the live entry must still carry exactly the +// content the tool applied (same field set and body normalization as the +// plan). A live entry whose body could not be read never matches - +// fail-closed toward refusal. +func AutoresponderMatchesContent(c *EmailAutoresponderContent, e NormAutoresponderEntry) bool { + return autoresponderContentEquivalent(c, e) +} + +// DefaultValuesEquivalent is the exported, same-account form of the +// class-aware default-address equality (exact match, or same +// :fail:/:blackhole:/account-username class) used by the apply/rollback +// verify paths. +func DefaultValuesEquivalent(a, b, accountUser string) bool { + return defaultsEquivalent(a, b, accountUser, accountUser) +} + +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// --- apply report ----------------------------------------------------------- + +// EmailOpResult is one plan op with its apply outcome. +type EmailOpResult struct { + EmailPlanOp + Status string `json:"status"` + StatusReason string `json:"status_reason,omitempty"` +} + +type EmailApplySummary struct { + Applied int `json:"applied"` + AlreadyPresent int `json:"already_present"` + Refused int `json:"refused_precondition"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Manual int `json:"manual"` +} + +// EmailApplyReport records what one `email apply` (or rollback) run +// actually did. The backup records the path of its paired report; the +// report records the path AND sha256 of its backup, bidirectional +// pairing, because the rollback needs the report to know which ops were +// ACTUALLY performed (a create can resolve to already_present). +type EmailApplyReport struct { + Mode string `json:"mode"` // "email-apply-report" + FormatVersion int `json:"format_version"` + RunMode string `json:"run_mode"` // "apply" | "rollback" + GeneratedAt string `json:"generated_at"` + DestinationUser string `json:"destination_user"` + PlanFile string `json:"plan_file,omitempty"` + PlanSHA256 string `json:"plan_sha256,omitempty"` + BackupFile string `json:"backup_file,omitempty"` + BackupSHA256 string `json:"backup_sha256,omitempty"` + // BackupNote documents WHY no backup exists when BackupFile is empty + // (e.g. zero writes decided), an empty path with no note is invalid. + BackupNote string `json:"backup_note,omitempty"` + Results []EmailOpResult `json:"results"` + Summary EmailApplySummary `json:"summary"` +} + +// SummarizeEmailResults recomputes the summary from the results. +func SummarizeEmailResults(results []EmailOpResult) EmailApplySummary { + var s EmailApplySummary + for _, r := range results { + switch r.Status { + case EmailOpApplied: + s.Applied++ + case EmailOpAlready: + s.AlreadyPresent++ + case EmailOpRefused: + s.Refused++ + case EmailOpFailed: + s.Failed++ + case EmailOpSkipped: + s.Skipped++ + case EmailOpManual: + s.Manual++ + } + } + return s +} + +// --- backup ----------------------------------------------------------------- + +// EmailBackupSection archives one section's verbatim UAPI response plus +// its normalized entries (2B design: raw + normalized). +type EmailBackupSection struct { + RawUAPIResponse json.RawMessage `json:"raw_uapi_response"` + Forwarders []NormForwarderEntry `json:"forwarders,omitempty"` + Defaults []DefaultAddressEntry `json:"default_addresses,omitempty"` + // Autoresponders (PR 2B-2): normalized entries with their bodies; + // RawGetResponses archives the verbatim per-address body reads keyed + // by address (the list raw alone carries no content). + Autoresponders []NormAutoresponderEntry `json:"autoresponders,omitempty"` + RawGetResponses map[string]json.RawMessage `json:"raw_get_responses,omitempty"` + // Filters (PR 2B-3): normalized entries with collected rules. + Filters []NormEmailFilterEntry `json:"filters,omitempty"` + // Routing (PR 2B-3): routing entries. + Routing []EmailRoutingEntry `json:"routing,omitempty"` +} + +// EmailBackup is the pre-write state of every section the plan touches. +// No backup file => no write. +type EmailBackup struct { + Mode string `json:"mode"` // "email-apply-backup" + FormatVersion int `json:"format_version"` + GeneratedAt string `json:"generated_at"` + DestinationUser string `json:"destination_user"` + PlanFile string `json:"plan_file,omitempty"` + PlanSHA256 string `json:"plan_sha256,omitempty"` + // ReportFile is the path of the paired apply report (recorded at + // backup time, the report path is known before the first write). + ReportFile string `json:"report_file"` + ForwardersByDomain map[string]EmailBackupSection `json:"forwarders_by_domain,omitempty"` + DefaultAddresses *EmailBackupSection `json:"default_addresses,omitempty"` + AutorespondersByDomain map[string]EmailBackupSection `json:"autoresponders_by_domain,omitempty"` + FiltersByAccount map[string]EmailBackupSection `json:"filters_by_account,omitempty"` + Routing *EmailBackupSection `json:"routing,omitempty"` +} + +// backupDefaultFor returns the backed-up default address for a domain. +func (b EmailBackup) backupDefaultFor(domain string) (string, bool) { + if b.DefaultAddresses == nil { + return "", false + } + for _, d := range b.DefaultAddresses.Defaults { + if strings.EqualFold(strings.TrimSpace(d.Domain), strings.TrimSpace(domain)) { + return d.DefaultAddress, true + } + } + return "", false +} + +// backupRoutingFor returns the backed-up routing value for a domain. +func (b EmailBackup) backupRoutingFor(domain string) (string, bool) { + if b.Routing == nil { + return "", false + } + for _, r := range b.Routing.Routing { + if strings.EqualFold(strings.TrimSpace(r.Domain), strings.TrimSpace(domain)) { + return r.Routing, true + } + } + return "", false +} + +// FilterMatchesContent is the exported content-equality check the +// rollback pre-check uses: the live entry must still carry exactly the +// content the tool applied. +func FilterMatchesContent(c *EmailFilterContent, e NormEmailFilterEntry) bool { + return filterContentEquivalent(c, e) +} + +// --- rollback --------------------------------------------------------------- + +// Rollback op kinds (verb-free names: this file is covered by the +// module-wide email write scan, only the writer files may name the API +// functions). +const ( + EmailRollbackForwarderRemove = "forwarder_remove" + EmailRollbackDefaultRestore = "default_restore" + EmailRollbackAutoresponderRemove = "autoresponder_remove" + EmailRollbackFilterRemove = "filter_remove" + EmailRollbackRoutingRestore = "routing_restore" +) + +// EmailRollbackOp is one inverse op, computed ONLY for ops the report +// records as applied. Each carries the post-apply state it expects to +// find: rollback refuses an item whose current state diverged (a human +// changed it since, explicit resolution required). +type EmailRollbackOp struct { + Kind string `json:"kind"` + Domain string `json:"domain"` + // forwarder_remove: the pair to delete (the tool's own create). + Address string `json:"address,omitempty"` + Forwarder string `json:"forwarder,omitempty"` + // default_restore: the backup value to restore. + Value string `json:"value,omitempty"` + // ExpectedCurrent documents the post-apply state the item must still + // be in: the pair present (forwarder_remove) / the applied value + // (default_restore, stored here). + ExpectedCurrent string `json:"expected_current,omitempty"` + // Autoresponder (autoresponder_remove): the content the tool applied - + // the expected-current state; rollback refuses to delete an + // autoresponder whose live content diverged from it (a human + // customized it since). + Autoresponder *EmailAutoresponderContent `json:"autoresponder,omitempty"` + // Filter (filter_remove): the content the tool applied (2B-3). + Filter *EmailFilterContent `json:"filter,omitempty"` + // Account carries the per-mailbox account scope for filter ops + // ("" = account-level). + Account string `json:"account,omitempty"` +} + +// ComputeEmailRollback derives the inverse ops from a report+backup pair: +// delete_forwarder-shaped inverses for the tool's own applied creates +// (the ONLY deletes the tool ever emits; already_present ops are NEVER +// inverted) and default-address restores back to the backup value for +// its own applied sets. It fails closed when the backup lacks a needed +// value. +func ComputeEmailRollback(report EmailApplyReport, backup EmailBackup) ([]EmailRollbackOp, error) { + if report.RunMode != "apply" { + return nil, fmt.Errorf("rollback needs an APPLY report, got run_mode %q (rolling back a rollback is not supported)", report.RunMode) + } + var out []EmailRollbackOp + for _, r := range report.Results { + if r.Status != EmailOpApplied { + continue + } + switch { + case r.Section == EmailSectionForwarders && r.Action == EmailActionCreate: + out = append(out, EmailRollbackOp{ + Kind: EmailRollbackForwarderRemove, + Domain: r.Domain, + Address: r.Key, + Forwarder: r.Forward, + }) + case r.Section == EmailSectionDefaultAddress && r.Action == EmailActionSet: + backupVal, ok := backup.backupDefaultFor(r.Domain) + if !ok { + return nil, fmt.Errorf("backup carries no default address for domain %s, cannot compute its rollback (fail-closed)", r.Domain) + } + out = append(out, EmailRollbackOp{ + Kind: EmailRollbackDefaultRestore, + Domain: r.Domain, + Value: backupVal, + ExpectedCurrent: r.Value, + }) + case r.Section == EmailSectionAutoresponders && r.Action == EmailActionCreate: + if r.Autoresponder == nil { + return nil, fmt.Errorf("applied autoresponder create for %s carries no content payload, cannot compute a guarded rollback (fail-closed)", r.Key) + } + out = append(out, EmailRollbackOp{ + Kind: EmailRollbackAutoresponderRemove, + Domain: r.Domain, + Address: r.Key, + Autoresponder: r.Autoresponder, + }) + case r.Section == EmailSectionFilters && r.Action == EmailActionCreate: + if r.Filter == nil { + return nil, fmt.Errorf("applied filter create for %s carries no content payload, cannot compute a guarded rollback (fail-closed)", r.Key) + } + account, filtername := splitFilterKey(r.Key) + out = append(out, EmailRollbackOp{ + Kind: EmailRollbackFilterRemove, + Address: filtername, + Filter: r.Filter, + Account: account, + }) + case r.Section == EmailSectionRouting && r.Action == EmailActionSet: + backupVal, ok := backup.backupRoutingFor(r.Domain) + if !ok { + return nil, fmt.Errorf("backup carries no routing for domain %s, cannot compute its rollback (fail-closed)", r.Domain) + } + out = append(out, EmailRollbackOp{ + Kind: EmailRollbackRoutingRestore, + Domain: r.Domain, + Value: backupVal, + ExpectedCurrent: r.Value, + }) + default: + return nil, fmt.Errorf("applied op %s/%s has unexpected shape (section %s, action %s), refusing to invert it", + r.Section, r.Key, r.Section, r.Action) + } + } + return out, nil +} + +// ComputeEmailRollbackDegraded is the DOCUMENTED report-loss degradation: +// without the report, the set of ops the tool actually performed is +// unknowable, so forwarder rollback is MANUAL (deleting "present now but +// absent in backup" could destroy a forwarder a human added post-apply - +// never-delete wins) and only the default-address restores remain +// computable from the backup values alone. The returned notes list what +// the operator must resolve by hand. +func ComputeEmailRollbackDegraded(backup EmailBackup) (ops []EmailRollbackOp, manualNotes []string) { + if backup.DefaultAddresses != nil { + for _, d := range backup.DefaultAddresses.Defaults { + ops = append(ops, EmailRollbackOp{ + Kind: EmailRollbackDefaultRestore, + Domain: strings.ToLower(strings.TrimSpace(d.Domain)), + Value: d.DefaultAddress, + }) + } + } + domains := make([]string, 0, len(backup.ForwardersByDomain)) + for d := range backup.ForwardersByDomain { + domains = append(domains, d) + } + sort.Strings(domains) + for _, d := range domains { + manualNotes = append(manualNotes, fmt.Sprintf( + "forwarders for %s: rollback is MANUAL without the report, compare the live list against the backup and remove only forwarders you know the tool created", d)) + } + arDomains := make([]string, 0, len(backup.AutorespondersByDomain)) + for d := range backup.AutorespondersByDomain { + arDomains = append(arDomains, d) + } + sort.Strings(arDomains) + for _, d := range arDomains { + manualNotes = append(manualNotes, fmt.Sprintf( + "autoresponders for %s: rollback is MANUAL without the report, compare the live list against the backup and remove only autoresponders you know the tool created", d)) + } + filterAccounts := make([]string, 0, len(backup.FiltersByAccount)) + for a := range backup.FiltersByAccount { + filterAccounts = append(filterAccounts, a) + } + sort.Strings(filterAccounts) + for _, a := range filterAccounts { + scope := a + if scope == "" { + scope = "(account-level)" + } + manualNotes = append(manualNotes, fmt.Sprintf( + "filters for %s: rollback is MANUAL without the report, compare the live list against the backup and remove only filters you know the tool created", scope)) + } + if backup.Routing != nil { + for _, r := range backup.Routing.Routing { + ops = append(ops, EmailRollbackOp{ + Kind: EmailRollbackRoutingRestore, + Domain: strings.ToLower(strings.TrimSpace(r.Domain)), + Value: r.Routing, + }) + } + } + sort.Slice(ops, func(i, j int) bool { return ops[i].Domain < ops[j].Domain }) + return ops, manualNotes +} diff --git a/internal/accountinventory/emailapply_test.go b/internal/accountinventory/emailapply_test.go new file mode 100644 index 00000000..247aa4be --- /dev/null +++ b/internal/accountinventory/emailapply_test.go @@ -0,0 +1,619 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "strings" + "testing" +) + +// --- fixtures --------------------------------------------------------------- + +func eaCreateOp() EmailPlanOp { + return EmailPlanOp{ + Section: EmailSectionForwarders, Action: EmailActionCreate, + Domain: "example.com", Key: "info@example.com", + Email: "info", Forward: "someone@gmail.com", + } +} + +func eaSetOp() EmailPlanOp { + return EmailPlanOp{ + Section: EmailSectionDefaultAddress, Action: EmailActionSet, + Domain: "example.com", Key: "example.com", + Value: "someone@gmail.com", DestinationValue: "acct", + } +} + +func eaLive(fwds []NormForwarderEntry, defaults []DefaultAddressEntry) EmailLiveState { + return EmailLiveState{ + ForwardersByDomain: map[string][]NormForwarderEntry{"example.com": fwds}, + ForwarderListErrors: map[string]string{}, + Defaults: defaults, + DefaultsListed: true, + } +} + +// --- EvaluateEmailOp: forwarder create -------------------------------------- + +func TestEvaluateForwarderCreate(t *testing.T) { + op := eaCreateOp() + + t.Run("precondition holds -> write", func(t *testing.T) { + live := eaLive(nil, nil) + if d, r := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionWrite { + t.Errorf("decision = %q (%s), want write", d, r) + } + }) + + t.Run("outcome present -> already_present (convergence)", func(t *testing.T) { + live := eaLive([]NormForwarderEntry{ + {Source: "info@example.com", Destination: "someone@gmail.com", Domain: "example.com"}, + }, nil) + if d, _ := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionAlready { + t.Errorf("decision = %q, want already_present", d) + } + }) + + t.Run("address gained a DIFFERENT forward -> refused", func(t *testing.T) { + live := eaLive([]NormForwarderEntry{ + {Source: "info@example.com", Destination: "third@party.com", Domain: "example.com"}, + }, nil) + d, r := EvaluateEmailOp(op, live, "acct") + if d != EmailDecisionRefused { + t.Errorf("decision = %q, want refused", d) + } + if !strings.Contains(r, "changed since the plan") { + t.Errorf("reason = %q", r) + } + }) + + t.Run("plan-time dest state still matching -> write", func(t *testing.T) { + opWithState := op + opWithState.PlanTimeDestForwards = []string{"old@gmail.com"} + live := eaLive([]NormForwarderEntry{ + {Source: "info@example.com", Destination: "old@gmail.com", Domain: "example.com"}, + }, nil) + if d, r := EvaluateEmailOp(opWithState, live, "acct"); d != EmailDecisionWrite { + t.Errorf("decision = %q (%s), want write", d, r) + } + }) + + t.Run("re-list failure -> refused fail-closed", func(t *testing.T) { + live := eaLive(nil, nil) + live.ForwarderListErrors["example.com"] = "ssh timeout" + if d, _ := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionRefused { + t.Errorf("decision = %q, want refused", d) + } + }) +} + +// --- EvaluateEmailOp: default address set ----------------------------------- + +func TestEvaluateDefaultSet(t *testing.T) { + op := eaSetOp() + + t.Run("still fresh -> write", func(t *testing.T) { + live := eaLive(nil, []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: "acct"}}) + if d, r := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionWrite { + t.Errorf("decision = %q (%s), want write", d, r) + } + }) + + t.Run("desired already live -> already_present", func(t *testing.T) { + live := eaLive(nil, []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: "someone@gmail.com"}}) + if d, _ := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionAlready { + t.Errorf("decision = %q, want already_present", d) + } + }) + + t.Run("changed to a third value -> refused", func(t *testing.T) { + live := eaLive(nil, []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: "third@party.com"}}) + if d, _ := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionRefused { + t.Errorf("decision = %q, want refused", d) + } + }) + + t.Run("domain vanished -> refused", func(t *testing.T) { + live := eaLive(nil, nil) + if d, _ := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionRefused { + t.Errorf("decision = %q, want refused", d) + } + }) + + t.Run("re-list failure -> refused fail-closed", func(t *testing.T) { + live := eaLive(nil, nil) + live.DefaultsListed = false + live.DefaultsError = "boom" + if d, _ := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionRefused { + t.Errorf("decision = %q, want refused", d) + } + }) + + t.Run("fail-class desired matches locale-different live tail", func(t *testing.T) { + opFail := op + opFail.Value = ":fail: No Such User Here" + live := eaLive(nil, []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: ":fail: no such address here"}}) + if d, _ := EvaluateEmailOp(opFail, live, "acct"); d != EmailDecisionAlready { + t.Errorf("decision = %q, want already_present (prefix-class equality)", d) + } + }) +} + +// EmailOutcomePresent is also the verify-after predicate. +func TestEmailOutcomePresent(t *testing.T) { + create, set := eaCreateOp(), eaSetOp() + live := eaLive( + []NormForwarderEntry{{Source: "info@example.com", Destination: "someone@gmail.com", Domain: "example.com"}}, + []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: "someone@gmail.com"}}, + ) + if !EmailOutcomePresent(create, live, "acct") || !EmailOutcomePresent(set, live, "acct") { + t.Error("both outcomes are live and must verify present") + } + empty := eaLive(nil, nil) + if EmailOutcomePresent(create, empty, "acct") || EmailOutcomePresent(set, empty, "acct") { + t.Error("no outcome is live, nothing must verify present") + } +} + +// --- rollback computation --------------------------------------------------- + +func eaReport(results ...EmailOpResult) EmailApplyReport { + return EmailApplyReport{ + Mode: "email-apply-report", FormatVersion: 1, RunMode: "apply", + Results: results, Summary: SummarizeEmailResults(results), + } +} + +func eaBackup() EmailBackup { + return EmailBackup{ + Mode: "email-apply-backup", FormatVersion: 1, + DefaultAddresses: &EmailBackupSection{ + Defaults: []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: "acct"}}, + }, + ForwardersByDomain: map[string]EmailBackupSection{ + "example.com": {Forwarders: []NormForwarderEntry{}}, + }, + } +} + +func TestComputeEmailRollbackInvertsOnlyApplied(t *testing.T) { + report := eaReport( + EmailOpResult{EmailPlanOp: eaCreateOp(), Status: EmailOpApplied}, + EmailOpResult{EmailPlanOp: eaSetOp(), Status: EmailOpApplied}, + EmailOpResult{EmailPlanOp: EmailPlanOp{ + Section: EmailSectionForwarders, Action: EmailActionCreate, + Domain: "example.com", Key: "other@example.com", Email: "other", Forward: "x@y.com", + }, Status: EmailOpAlready}, // NEVER inverted + EmailOpResult{EmailPlanOp: EmailPlanOp{ + Section: EmailSectionForwarders, Action: EmailActionManual, Key: "m@example.com", + }, Status: EmailOpManual}, + ) + + ops, err := ComputeEmailRollback(report, eaBackup()) + if err != nil { + t.Fatal(err) + } + if len(ops) != 2 { + t.Fatalf("rollback ops = %d, want 2 (applied only): %+v", len(ops), ops) + } + var fwd, def *EmailRollbackOp + for i := range ops { + switch ops[i].Kind { + case EmailRollbackForwarderRemove: + fwd = &ops[i] + case EmailRollbackDefaultRestore: + def = &ops[i] + } + } + if fwd == nil || fwd.Address != "info@example.com" || fwd.Forwarder != "someone@gmail.com" { + t.Errorf("forwarder inverse = %+v", fwd) + } + if def == nil || def.Value != "acct" || def.ExpectedCurrent != "someone@gmail.com" { + t.Errorf("default inverse = %+v", def) + } +} + +func TestComputeEmailRollbackFailsClosedOnMissingBackupValue(t *testing.T) { + report := eaReport(EmailOpResult{EmailPlanOp: eaSetOp(), Status: EmailOpApplied}) + backup := eaBackup() + backup.DefaultAddresses = nil + if _, err := ComputeEmailRollback(report, backup); err == nil { + t.Error("missing backup value must fail closed") + } +} + +func TestComputeEmailRollbackRefusesRollbackReport(t *testing.T) { + report := eaReport() + report.RunMode = "rollback" + if _, err := ComputeEmailRollback(report, eaBackup()); err == nil { + t.Error("rolling back a rollback report must be refused") + } +} + +func TestComputeEmailRollbackDegraded(t *testing.T) { + ops, notes := ComputeEmailRollbackDegraded(eaBackup()) + if len(ops) != 1 || ops[0].Kind != EmailRollbackDefaultRestore || ops[0].Value != "acct" { + t.Errorf("degraded ops = %+v", ops) + } + if len(notes) != 1 || !strings.Contains(notes[0], "MANUAL") { + t.Errorf("degraded notes = %v", notes) + } +} + +// --- EvaluateEmailOp: autoresponder create (PR 2B-2) ------------------------- + +func eaAutoresponderOp() EmailPlanOp { + return EmailPlanOp{ + Section: EmailSectionAutoresponders, Action: EmailActionCreate, + Domain: "example.com", Key: "info@example.com", Email: "info", + Autoresponder: &EmailAutoresponderContent{ + From: "Info Desk", Subject: "Out of office", + Body: "On vacation.\n", IsHTML: 0, Interval: 8, Charset: "utf-8", + }, + } +} + +func eaLiveAutoresponders(ars []NormAutoresponderEntry) EmailLiveState { + live := eaLive(nil, nil) + live.AutorespondersByDomain = map[string][]NormAutoresponderEntry{"example.com": ars} + live.AutoresponderListErrors = map[string]string{} + return live +} + +func eaLiveAutoresponderEntry() NormAutoresponderEntry { + return NormAutoresponderEntry{ + Email: "info@example.com", Domain: "example.com", + Subject: "Out of office", From: "Info Desk", + Body: "On vacation.\n", IsHTML: 0, Interval: 8, Charset: "utf-8", + BodyCollected: true, + } +} + +func TestEvaluateAutoresponderCreate(t *testing.T) { + op := eaAutoresponderOp() + + t.Run("address empty -> write", func(t *testing.T) { + live := eaLiveAutoresponders(nil) + if d, r := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionWrite { + t.Errorf("decision = %q (%s), want write", d, r) + } + }) + + t.Run("outcome present (equivalent content) -> already_present", func(t *testing.T) { + live := eaLiveAutoresponders([]NormAutoresponderEntry{eaLiveAutoresponderEntry()}) + if d, _ := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionAlready { + t.Errorf("decision = %q, want already_present", d) + } + }) + + t.Run("different content on the address -> refused (never overwrite)", func(t *testing.T) { + e := eaLiveAutoresponderEntry() + e.Body = "Qualcun altro ha scritto questo.\n" + live := eaLiveAutoresponders([]NormAutoresponderEntry{e}) + d, r := EvaluateEmailOp(op, live, "acct") + if d != EmailDecisionRefused { + t.Errorf("decision = %q, want refused", d) + } + if !strings.Contains(r, "overwrite") { + t.Errorf("reason = %q, should explain the never-overwrite refusal", r) + } + }) + + t.Run("present but body unreadable -> refused fail-closed", func(t *testing.T) { + e := eaLiveAutoresponderEntry() + e.Body, e.BodyCollected = "", false + live := eaLiveAutoresponders([]NormAutoresponderEntry{e}) + if d, _ := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionRefused { + t.Errorf("decision = %q, want refused", d) + } + }) + + t.Run("re-list failure -> refused fail-closed", func(t *testing.T) { + live := eaLiveAutoresponders(nil) + live.AutoresponderListErrors["example.com"] = "ssh timeout" + if d, _ := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionRefused { + t.Errorf("decision = %q, want refused", d) + } + }) + + t.Run("op without payload -> refused (malformed plan)", func(t *testing.T) { + broken := op + broken.Autoresponder = nil + live := eaLiveAutoresponders(nil) + if d, _ := EvaluateEmailOp(broken, live, "acct"); d != EmailDecisionRefused { + t.Errorf("decision = %q, want refused", d) + } + }) +} + +func TestEmailOutcomePresentAutoresponder(t *testing.T) { + op := eaAutoresponderOp() + if EmailOutcomePresent(op, eaLiveAutoresponders(nil), "acct") { + t.Error("outcome present on an empty address") + } + if !EmailOutcomePresent(op, eaLiveAutoresponders([]NormAutoresponderEntry{eaLiveAutoresponderEntry()}), "acct") { + t.Error("outcome NOT present with the equivalent live entry") + } + // Trailing-newline normalization (2B-2-pre fact 5) must apply. + e := eaLiveAutoresponderEntry() + e.Body = "On vacation.\n\n\n" + if !EmailOutcomePresent(op, eaLiveAutoresponders([]NormAutoresponderEntry{e}), "acct") { + t.Error("outcome NOT present with a trailing-newline-only difference") + } + diff := eaLiveAutoresponderEntry() + diff.Subject = "Other" + if EmailOutcomePresent(op, eaLiveAutoresponders([]NormAutoresponderEntry{diff}), "acct") { + t.Error("outcome present with a DIFFERENT live entry") + } +} + +func TestComputeEmailRollbackAutoresponderCreate(t *testing.T) { + op := eaAutoresponderOp() + report := EmailApplyReport{ + RunMode: "apply", + Results: []EmailOpResult{ + {EmailPlanOp: op, Status: EmailOpApplied}, + {EmailPlanOp: eaAutoresponderOp(), Status: EmailOpAlready}, // NEVER inverted + }, + } + report.Results[1].Key = "other@example.com" + backup := EmailBackup{} + + ops, err := ComputeEmailRollback(report, backup) + if err != nil { + t.Fatalf("ComputeEmailRollback: %v", err) + } + if len(ops) != 1 { + t.Fatalf("ops = %+v, want exactly the own applied create inverted", ops) + } + ro := ops[0] + if ro.Kind != EmailRollbackAutoresponderRemove { + t.Errorf("kind = %q", ro.Kind) + } + if ro.Address != "info@example.com" || ro.Domain != "example.com" { + t.Errorf("target = %s / %s", ro.Address, ro.Domain) + } + if ro.Autoresponder == nil || ro.Autoresponder.Subject != "Out of office" { + t.Errorf("the inverse op must carry the applied content as its expected-current state: %+v", ro.Autoresponder) + } +} + +func TestComputeEmailRollbackDegradedAutorespondersAreManual(t *testing.T) { + backup := EmailBackup{ + AutorespondersByDomain: map[string]EmailBackupSection{ + "example.com": {Autoresponders: []NormAutoresponderEntry{eaLiveAutoresponderEntry()}}, + }, + } + ops, notes := ComputeEmailRollbackDegraded(backup) + for _, o := range ops { + if o.Kind == EmailRollbackAutoresponderRemove { + t.Fatalf("degraded rollback computed an autoresponder DELETE without the report: %+v", o) + } + } + found := false + for _, n := range notes { + if strings.Contains(n, "autoresponder") { + found = true + } + } + if !found { + t.Errorf("degraded rollback must flag autoresponders as MANUAL, notes = %v", notes) + } +} + +// --- EvaluateEmailOp: filter create (2B-3) --------------------------------- + +func eaFilterCreateOp() EmailPlanOp { + return EmailPlanOp{ + Section: EmailSectionFilters, Action: EmailActionCreate, + Key: "(account-level)/test-filter", + Filter: &EmailFilterContent{ + Rules: []FilterRule{{Part: "$header_From:", Match: "contains", Val: "spam@x.com"}}, + Actions: []FilterAction{{Action: "fail"}}, + }, + } +} + +func eaFilterLive(filters []NormEmailFilterEntry) EmailLiveState { + return EmailLiveState{ + ForwardersByDomain: map[string][]NormForwarderEntry{}, + ForwarderListErrors: map[string]string{}, + DefaultsListed: true, + FiltersByAccount: map[string][]NormEmailFilterEntry{"": filters}, + FilterListErrors: map[string]string{}, + RoutingListed: true, + } +} + +func TestEvaluateFilterCreate(t *testing.T) { + op := eaFilterCreateOp() + + t.Run("absent -> write", func(t *testing.T) { + live := eaFilterLive(nil) + if d, r := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionWrite { + t.Errorf("decision = %q (%s), want write", d, r) + } + }) + + t.Run("content-identical -> already", func(t *testing.T) { + live := eaFilterLive([]NormEmailFilterEntry{{ + FilterName: "test-filter", + Rules: []FilterRule{{Part: "$header_From:", Match: "contains", Val: "spam@x.com"}}, + Actions: []FilterAction{{Action: "fail"}}, + RulesCollected: true, + }}) + if d, _ := EvaluateEmailOp(op, live, "acct"); d != EmailDecisionAlready { + t.Errorf("decision = %q, want already", d) + } + }) + + t.Run("different content -> refused", func(t *testing.T) { + live := eaFilterLive([]NormEmailFilterEntry{{ + FilterName: "test-filter", + Rules: []FilterRule{{Part: "$header_To:", Match: "is", Val: "other@x.com"}}, + Actions: []FilterAction{{Action: "finish"}}, + RulesCollected: true, + }}) + d, r := EvaluateEmailOp(op, live, "acct") + if d != EmailDecisionRefused { + t.Errorf("decision = %q (%s), want refused", d, r) + } + }) + + t.Run("rules unreadable -> refused", func(t *testing.T) { + live := eaFilterLive([]NormEmailFilterEntry{{ + FilterName: "test-filter", + RulesCollected: false, + }}) + d, r := EvaluateEmailOp(op, live, "acct") + if d != EmailDecisionRefused { + t.Errorf("decision = %q (%s), want refused", d, r) + } + }) + + t.Run("nil payload -> refused", func(t *testing.T) { + nilOp := eaFilterCreateOp() + nilOp.Filter = nil + live := eaFilterLive(nil) + d, r := EvaluateEmailOp(nilOp, live, "acct") + if d != EmailDecisionRefused { + t.Errorf("decision = %q (%s), want refused", d, r) + } + }) + + t.Run("re-list failed -> refused", func(t *testing.T) { + live := eaFilterLive(nil) + live.FilterListErrors[""] = "ssh timeout" + d, r := EvaluateEmailOp(op, live, "acct") + if d != EmailDecisionRefused { + t.Errorf("decision = %q (%s), want refused", d, r) + } + }) +} + +// --- EvaluateEmailOp: routing set (2B-3) ----------------------------------- + +func eaRoutingSetOp() EmailPlanOp { + return EmailPlanOp{ + Section: EmailSectionRouting, Action: EmailActionSet, + Domain: "example.com", Key: "example.com", + Value: "remote", DestinationValue: "local", + } +} + +func eaRoutingLive(routing string) EmailLiveState { + return EmailLiveState{ + ForwardersByDomain: map[string][]NormForwarderEntry{}, + ForwarderListErrors: map[string]string{}, + DefaultsListed: true, + FiltersByAccount: map[string][]NormEmailFilterEntry{}, + FilterListErrors: map[string]string{}, + RoutingEntries: []EmailRoutingEntry{{Domain: "example.com", Routing: routing}}, + RoutingListed: true, + } +} + +func TestEvaluateRoutingSet(t *testing.T) { + op := eaRoutingSetOp() + + t.Run("plan-time state holds -> write", func(t *testing.T) { + if d, r := EvaluateEmailOp(op, eaRoutingLive("local"), "acct"); d != EmailDecisionWrite { + t.Errorf("decision = %q (%s), want write", d, r) + } + }) + + t.Run("already set -> already", func(t *testing.T) { + if d, _ := EvaluateEmailOp(op, eaRoutingLive("remote"), "acct"); d != EmailDecisionAlready { + t.Errorf("decision = %q, want already", d) + } + }) + + t.Run("third value -> refused", func(t *testing.T) { + d, r := EvaluateEmailOp(op, eaRoutingLive("secondary"), "acct") + if d != EmailDecisionRefused { + t.Errorf("decision = %q (%s), want refused", d, r) + } + }) + + t.Run("re-list failed -> refused", func(t *testing.T) { + live := eaRoutingLive("local") + live.RoutingListed = false + live.RoutingError = "ssh timeout" + d, r := EvaluateEmailOp(op, live, "acct") + if d != EmailDecisionRefused { + t.Errorf("decision = %q (%s), want refused", d, r) + } + }) + + t.Run("domain vanished -> refused", func(t *testing.T) { + live := eaRoutingLive("local") + live.RoutingEntries = nil + d, r := EvaluateEmailOp(op, live, "acct") + if d != EmailDecisionRefused { + t.Errorf("decision = %q (%s), want refused", d, r) + } + }) +} + +// --- ComputeEmailRollback: filter + routing (2B-3) ------------------------- + +func TestRollbackFilterCreate(t *testing.T) { + report := EmailApplyReport{ + RunMode: "apply", + Results: []EmailOpResult{{ + EmailPlanOp: EmailPlanOp{ + Section: EmailSectionFilters, Action: EmailActionCreate, + Key: "(account-level)/test-filter", + Filter: &EmailFilterContent{ + Rules: []FilterRule{{Part: "$header_From:", Match: "contains", Val: "spam@x.com"}}, + Actions: []FilterAction{{Action: "fail"}}, + }, + }, + Status: EmailOpApplied, + }}, + } + ops, err := ComputeEmailRollback(report, EmailBackup{}) + if err != nil { + t.Fatalf("rollback: %v", err) + } + if len(ops) != 1 || ops[0].Kind != EmailRollbackFilterRemove { + t.Fatalf("ops = %+v, want 1 filter_remove", ops) + } + if ops[0].Address != "test-filter" || ops[0].Account != "" { + t.Errorf("rollback op = %+v, want address=test-filter account=''", ops[0]) + } + if ops[0].Filter == nil { + t.Error("rollback op must carry the filter content for the equivalence guard") + } +} + +func TestRollbackRoutingSet(t *testing.T) { + report := EmailApplyReport{ + RunMode: "apply", + Results: []EmailOpResult{{ + EmailPlanOp: EmailPlanOp{ + Section: EmailSectionRouting, Action: EmailActionSet, + Domain: "example.com", Key: "example.com", + Value: "remote", + }, + Status: EmailOpApplied, + }}, + } + backup := EmailBackup{ + Routing: &EmailBackupSection{ + Routing: []EmailRoutingEntry{{Domain: "example.com", Routing: "local"}}, + }, + } + ops, err := ComputeEmailRollback(report, backup) + if err != nil { + t.Fatalf("rollback: %v", err) + } + if len(ops) != 1 || ops[0].Kind != EmailRollbackRoutingRestore { + t.Fatalf("ops = %+v, want 1 routing_restore", ops) + } + if ops[0].Value != "local" || ops[0].ExpectedCurrent != "remote" { + t.Errorf("rollback op = %+v, want value=local expected=remote", ops[0]) + } +} diff --git a/internal/accountinventory/emailapply_write.go b/internal/accountinventory/emailapply_write.go new file mode 100644 index 00000000..14eaa6f2 --- /dev/null +++ b/internal/accountinventory/emailapply_write.go @@ -0,0 +1,93 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// WriteEmailBackupJSON writes the pre-write backup. Mode 0600: it holds +// verbatim addresses. +func WriteEmailBackupJSON(path string, b EmailBackup) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + data, err := json.MarshalIndent(b, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal email backup: %w", err) + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} + +// WriteEmailApplyReportJSON writes the machine-readable apply report. +func WriteEmailApplyReportJSON(path string, r EmailApplyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal email apply report: %w", err) + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} + +// WriteEmailApplyReportMarkdown writes the human-readable apply report. +// Failures and refusals come first: they are what the operator must act on. +func WriteEmailApplyReportMarkdown(path string, r EmailApplyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + title := "Email Apply Report" + if r.RunMode == "rollback" { + title = "Email Rollback Report" + } + fmt.Fprintf(&sb, "# %s\n\n", title) + fmt.Fprintf(&sb, "- **Run mode**: %s\n", r.RunMode) + fmt.Fprintf(&sb, "- **Destination account**: %s\n", r.DestinationUser) + if r.PlanFile != "" { + fmt.Fprintf(&sb, "- **Plan**: %s (sha256 %s)\n", r.PlanFile, r.PlanSHA256) + } + if r.BackupFile != "" { + fmt.Fprintf(&sb, "- **Backup**: %s (sha256 %s)\n", r.BackupFile, r.BackupSHA256) + } else if r.BackupNote != "" { + fmt.Fprintf(&sb, "- **Backup**: none, %s\n", r.BackupNote) + } + fmt.Fprintf(&sb, "- **Generated**: %s\n\n", r.GeneratedAt) + fmt.Fprintf(&sb, "**Summary**: %d applied, %d already present, %d refused (precondition), %d failed, %d skipped, %d manual\n\n", + r.Summary.Applied, r.Summary.AlreadyPresent, r.Summary.Refused, + r.Summary.Failed, r.Summary.Skipped, r.Summary.Manual) + + order := []string{EmailOpFailed, EmailOpRefused, EmailOpApplied, EmailOpAlready, EmailOpManual, EmailOpSkipped} + for _, status := range order { + var rows []EmailOpResult + for _, res := range r.Results { + if res.Status == status { + rows = append(rows, res) + } + } + if len(rows) == 0 { + continue + } + fmt.Fprintf(&sb, "## %s (%d)\n\n", strings.ToUpper(strings.ReplaceAll(status, "_", " ")), len(rows)) + sb.WriteString("| Section | Key | Detail | Note |\n|---------|-----|--------|------|\n") + for _, res := range rows { + detail := emailOpDesired(res.EmailPlanOp) + note := res.StatusReason + if note == "" { + note = res.Reason + } + fmt.Fprintf(&sb, "| %s | %s | %s | %s |\n", + mdCell(res.Section, 20), mdCell(res.Key, 60), mdCell(detail, 90), mdCell(note, 110)) + } + sb.WriteString("\n") + } + return os.WriteFile(path, []byte(sb.String()), 0o600) +} diff --git a/internal/accountinventory/emailplan.go b/internal/accountinventory/emailplan.go new file mode 100644 index 00000000..855e8bed --- /dev/null +++ b/internal/accountinventory/emailplan.go @@ -0,0 +1,876 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "fmt" + "sort" + "strings" +) + +// Email apply plan (PR 2B-1). BuildEmailPlan is fully offline: it consumes +// two normalized inventories and produces a reviewable plan of what +// `email apply` would write into the DESTINATION account's email +// configuration. It never connects anywhere and never generates delete +// ops for destination-only resources; the design and its safety rules +// live in docs/dev/PR2B_EMAIL_APPLY_DESIGN.md and PR2B_PRE_CAPTURES.md. +// +// 2B-1 computes actionable ops for forwarders and default (catch-all) +// addresses only - the two real Fase 0.2 blockers. Autoresponders, email +// filters and email routing are carried in the plan from day one so the +// checklist picture stays complete, but only as skip/manual: their +// writers land in 2B-2/2B-3. + +// Plan op actions. EmailActionManual is terminal: "flagged but applied +// anyway" does not exist. +const ( + EmailActionCreate = "create" // forwarder pair missing on destination + EmailActionSet = "set" // default address: dest still carries a fresh-account default + EmailActionSkip = "skip" // already satisfied on the destination + EmailActionManual = "manual" // the tool refuses to touch it (terminal) +) + +// Plan sections, matching the inventory/diff/policy section names. +const ( + EmailSectionForwarders = "forwarders" + EmailSectionDefaultAddress = "default_address" + EmailSectionAutoresponders = "autoresponders" + EmailSectionFilters = "email_filters" + EmailSectionRouting = "email_routing" +) + +// emailPlanSectionOrder fixes the deterministic section order of the ops +// list (writable sections first). +var emailPlanSectionOrder = map[string]int{ + EmailSectionForwarders: 0, + EmailSectionDefaultAddress: 1, + EmailSectionAutoresponders: 2, + EmailSectionFilters: 3, + EmailSectionRouting: 4, +} + +// freshDefaultAssumption is the documented safety assumption of the `set` +// classification; the plan Markdown carries it verbatim (design rule). +const freshDefaultAssumption = "DOCUMENTED ASSUMPTION: the `set` classification treats the literal " + + "account username and the `:fail:`/`:blackhole:` system forms (prefix-matched - the " + + "human-readable tail is locale-dependent) as fresh-account defaults. This is safe because " + + "the campaign's destination accounts are created FRESH by us; `:fail:`/username are also " + + "legitimate deliberate choices, so pointing `email apply` at a pre-existing, " + + "human-configured destination account makes the `set` classification unsafe." + +// EmailPlanOp is the decision for one email-config item. +type EmailPlanOp struct { + Section string `json:"section"` + Action string `json:"action"` + Domain string `json:"domain"` + // Key identifies the item inside its section: forwarders -> the + // canonical source address; default_address / email_routing -> the + // domain; autoresponders -> the autoresponder address; email_filters -> + // "account/filter name". + Key string `json:"key"` + Reason string `json:"reason,omitempty"` + // create (forwarders): Email::add_forwarder parameters - email is the + // LOCAL part, Forward the single target address (2B-pre contract). + Email string `json:"email,omitempty"` + Forward string `json:"forward,omitempty"` + // set (default_address): the desired value, verbatim from the source. + Value string `json:"value,omitempty"` + // Display context + apply-time preconditions. + SourceValue string `json:"source_value,omitempty"` + DestinationValue string `json:"destination_value,omitempty"` + // PlanTimeDestForwards records, for a forwarder create op, the + // destination's forwarder targets for this source address at plan + // time - the per-op precondition `email apply` re-checks against a + // fresh re-list before writing (the email analogue of the DNS serial). + PlanTimeDestForwards []string `json:"plan_time_dest_forwards,omitempty"` + // Autoresponder carries the full content payload of an autoresponder + // create op (PR 2B-2). Its plan-time precondition is implicit in the + // action: the destination address had NO autoresponder (a differing + // one is terminal manual - the writer never overwrites). + Autoresponder *EmailAutoresponderContent `json:"autoresponder,omitempty"` + // Filter carries the full content payload of a filter create op + // (PR 2B-3). Single-rule only (match_type not round-trippable). + Filter *EmailFilterContent `json:"filter,omitempty"` +} + +// EmailFilterContent is the round-trippable filter content (2B-3-pre +// facts 1-6): exactly the fields the writer sends and the verify paths +// compare. [WARN] match_type is NOT round-trippable (fact 10): single-rule +// filters use "is" as the safe default; multi-rule filters are MANUAL. +type EmailFilterContent struct { + Rules []FilterRule `json:"rules"` + Actions []FilterAction `json:"actions"` +} + +// EmailAutoresponderContent is the round-trippable autoresponder content +// (get_auto_responder -> add_auto_responder, 2B-2-pre facts 3/5): exactly +// the fields the writer sends and the verify paths compare. +type EmailAutoresponderContent struct { + From string `json:"from"` + Subject string `json:"subject"` + Body string `json:"body"` + IsHTML int `json:"is_html"` + Interval int `json:"interval"` + Start int64 `json:"start,omitempty"` + Stop int64 `json:"stop,omitempty"` + Charset string `json:"charset,omitempty"` +} + +// EmailPlanInfo describes a destination-only item: listed so a human sees +// it, never deleted (additive posture). +type EmailPlanInfo struct { + Section string `json:"section"` + Domain string `json:"domain"` + Key string `json:"key"` + Value string `json:"value"` +} + +// EmailManualSection is a section the plan refuses to compute ops for +// (unavailable on either side - fail-safe, mirrors ManualZone). +type EmailManualSection struct { + Section string `json:"section"` + Reason string `json:"reason"` +} + +type EmailPlanSummary struct { + Create int `json:"create"` + Set int `json:"set"` + Skip int `json:"skip"` + Manual int `json:"manual"` + Informational int `json:"informational"` +} + +type EmailApplyPlan struct { + Mode string `json:"mode"` + FormatVersion int `json:"format_version"` + GeneratedAt string `json:"generated_at"` + SourceFile string `json:"source_file,omitempty"` + SourceSHA256 string `json:"source_sha256,omitempty"` + DestinationFile string `json:"destination_file,omitempty"` + DestinationSHA256 string `json:"destination_sha256,omitempty"` + PolicyFile string `json:"policy_file,omitempty"` + SourceUser string `json:"source_user"` + DestinationUser string `json:"destination_user"` + Ops []EmailPlanOp `json:"ops"` + Informational []EmailPlanInfo `json:"informational,omitempty"` + ManualSections []EmailManualSection `json:"manual_sections,omitempty"` + PolicyFindings []string `json:"policy_findings,omitempty"` + NonEmailBlockers []string `json:"non_email_blockers,omitempty"` + Summary EmailPlanSummary `json:"summary"` +} + +// canonEmailAddr canonicalizes an email address (or address-like value) +// for comparison: trimmed and lowercased. cPanel stores mailbox/forwarder +// addresses lowercase; the inventory keeps them verbatim. +func canonEmailAddr(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +// splitEmailAddr splits local@domain, requiring exactly one "@" with +// non-empty halves. Anything else cannot be expressed as add_forwarder +// parameters and fails safe into manual. +func splitEmailAddr(s string) (local, domain string, ok bool) { + s = strings.TrimSpace(s) + if strings.Count(s, "@") != 1 { + return "", "", false + } + local, domain, _ = strings.Cut(s, "@") + if local == "" || domain == "" { + return "", "", false + } + return local, domain, true +} + +// isSimpleForwardTarget reports whether a cPanel `forward` value is a +// single plain email address - the ONLY form add_forwarder +// fwdopt=fwd/fwdemail= can round-trip. Multi-target comma-joined values, +// pipes, deliver-to-file paths, :fail:/:blackhole: system forms and +// deliver-to-account bare usernames all fail this check (terminal manual, +// per the 2B design). +func isSimpleForwardTarget(s string) bool { + t := strings.TrimSpace(s) + if t == "" || strings.ContainsAny(t, ", \t|") { + return false + } + if strings.HasPrefix(t, ":") || strings.HasPrefix(t, "/") { + return false + } + local, domain, ok := splitEmailAddr(t) + if !ok || local == "" { + return false + } + // A target domain without a dot (user@localhost) is suspicious enough + // to review by hand - fail-safe. + return strings.Contains(domain, ".") +} + +// Default-address value classes. list_default_address values are kept +// verbatim since 7E; the :fail:/:blackhole: system forms are matched by +// PREFIX because the human-readable tail is locale-dependent. +const ( + defaultClassFail = "fail" + defaultClassBlackhole = "blackhole" + defaultClassAccountDefault = "account_default" + defaultClassAddress = "address" + defaultClassOther = "other" +) + +func classifyDefaultValue(v, accountUser string) string { + t := strings.TrimSpace(v) + switch { + case strings.HasPrefix(t, ":fail:"): + return defaultClassFail + case strings.HasPrefix(t, ":blackhole:"): + return defaultClassBlackhole + case t == accountUser: + return defaultClassAccountDefault + case isSimpleForwardTarget(t): + return defaultClassAddress + default: + return defaultClassOther + } +} + +// defaultsEquivalent reports whether the source and destination default +// addresses are behaviorally the same: exact match, same system-form +// class (locale-independent), or both carrying their own account's +// fresh default (deliver to the account owner on either side). +func defaultsEquivalent(srcVal, destVal, srcUser, destUser string) bool { + if strings.TrimSpace(srcVal) == strings.TrimSpace(destVal) { + return true + } + sc := classifyDefaultValue(srcVal, srcUser) + dc := classifyDefaultValue(destVal, destUser) + if sc == dc && (sc == defaultClassFail || sc == defaultClassBlackhole || sc == defaultClassAccountDefault) { + return true + } + return false +} + +// isFreshDefault reports whether a DESTINATION default address still +// carries a fresh-account form (never customized by a human): the literal +// account username, or a :fail:/:blackhole: system form. +func isFreshDefault(v, destUser string) bool { + switch classifyDefaultValue(v, destUser) { + case defaultClassFail, defaultClassBlackhole, defaultClassAccountDefault: + return true + } + return false +} + +// BuildEmailPlan computes the email apply plan. policy is optional +// context (findings are cross-referenced, never gating - 6A precedent). +// It cannot fail: every unprovable state degrades to manual ops or +// manual_sections instead of erroring (fail-safe by construction). +func BuildEmailPlan(src, dest NormalizedInventory, policy *PolicyReport) EmailApplyPlan { + plan := EmailApplyPlan{ + Mode: "email-apply-plan", + FormatVersion: 1, + SourceUser: src.Account.User, + DestinationUser: dest.Account.User, + // Ops is seeded non-nil so the JSON stays array-typed (diff.go + // convention). + Ops: []EmailPlanOp{}, + } + + planForwarders(&plan, src, dest) + planDefaultAddresses(&plan, src, dest) + planAutoresponders(&plan, src, dest) + planFilters(&plan, src, dest) + planRouting(&plan, src, dest) + + plan.PolicyFindings = emailPolicyFindings(policy) + plan.NonEmailBlockers = nonEmailBlockers(policy) + + sort.Slice(plan.Ops, func(i, j int) bool { return emailOpLess(plan.Ops[i], plan.Ops[j]) }) + sort.Slice(plan.Informational, func(i, j int) bool { + a, b := plan.Informational[i], plan.Informational[j] + if a.Section != b.Section { + return emailPlanSectionOrder[a.Section] < emailPlanSectionOrder[b.Section] + } + if a.Domain != b.Domain { + return a.Domain < b.Domain + } + if a.Key != b.Key { + return a.Key < b.Key + } + return a.Value < b.Value + }) + sort.Slice(plan.ManualSections, func(i, j int) bool { + return emailPlanSectionOrder[plan.ManualSections[i].Section] < emailPlanSectionOrder[plan.ManualSections[j].Section] + }) + + for _, op := range plan.Ops { + switch op.Action { + case EmailActionCreate: + plan.Summary.Create++ + case EmailActionSet: + plan.Summary.Set++ + case EmailActionSkip: + plan.Summary.Skip++ + case EmailActionManual: + plan.Summary.Manual++ + } + } + plan.Summary.Informational = len(plan.Informational) + return plan +} + +func emailOpLess(a, b EmailPlanOp) bool { + if a.Section != b.Section { + return emailPlanSectionOrder[a.Section] < emailPlanSectionOrder[b.Section] + } + if a.Domain != b.Domain { + return a.Domain < b.Domain + } + if a.Key != b.Key { + return a.Key < b.Key + } + return a.Forward < b.Forward +} + +// forwarderPair is the canonical identity of one forwarder. +type forwarderPair struct { + Addr string + Target string +} + +// planForwarders applies the 2B rule table to the forwarder sections. +// NOTE: the forwarders inventory section has no availability flag (a +// per-domain collection failure surfaces only as an inventory warning), +// so the plan cannot gate on it; the diff/policy layers share the same +// blindness. Dest-only pairs are informational, never deleted. +func planForwarders(plan *EmailApplyPlan, src, dest NormalizedInventory) { + destDomains := map[string]bool{} + for _, d := range dest.Domains { + destDomains[strings.ToLower(d.Name)] = true + } + + destPairs := map[forwarderPair]bool{} + destTargets := map[string][]string{} + for _, f := range dest.Forwarders { + addr := canonEmailAddr(f.Source) + pair := forwarderPair{Addr: addr, Target: canonEmailAddr(f.Destination)} + if !destPairs[pair] { + destPairs[pair] = true + destTargets[addr] = append(destTargets[addr], strings.TrimSpace(f.Destination)) + } + } + for _, ts := range destTargets { + sort.Strings(ts) + } + + srcPairs := map[forwarderPair]bool{} + seen := map[forwarderPair]bool{} + for _, f := range src.Forwarders { + addr := canonEmailAddr(f.Source) + pair := forwarderPair{Addr: addr, Target: canonEmailAddr(f.Destination)} + if seen[pair] { + continue // duplicate source rows collapse into one op + } + seen[pair] = true + srcPairs[pair] = true + + op := EmailPlanOp{ + Section: EmailSectionForwarders, + Key: addr, + SourceValue: strings.TrimSpace(f.Destination), + DestinationValue: strings.Join(destTargets[addr], ", "), + } + local, domain, ok := splitEmailAddr(f.Source) + if ok { + op.Domain = strings.ToLower(domain) + } else { + op.Domain = strings.ToLower(strings.TrimSpace(f.Domain)) + } + + target := strings.TrimSpace(f.Destination) + switch { + case !ok: + op.Action = EmailActionManual + op.Reason = fmt.Sprintf("source address %q does not split into local@domain - cannot be expressed as forwarder-write parameters", strings.TrimSpace(f.Source)) + case destPairs[pair]: + op.Action = EmailActionSkip + case !isSimpleForwardTarget(target): + op.Action = EmailActionManual + op.Reason = fmt.Sprintf("non-single-address forward (raw: %q) - the forwarder writer only round-trips a single plain address; recreate by hand", target) + case !destDomains[op.Domain]: + op.Action = EmailActionManual + op.Reason = fmt.Sprintf("domain %s is missing on destination - create it first, then re-plan", op.Domain) + default: + op.Action = EmailActionCreate + op.Email = strings.ToLower(local) + op.Forward = target + op.PlanTimeDestForwards = destTargets[addr] + } + plan.Ops = append(plan.Ops, op) + } + + for _, f := range dest.Forwarders { + pair := forwarderPair{Addr: canonEmailAddr(f.Source), Target: canonEmailAddr(f.Destination)} + if srcPairs[pair] { + continue + } + plan.Informational = append(plan.Informational, EmailPlanInfo{ + Section: EmailSectionForwarders, + Domain: strings.ToLower(strings.TrimSpace(f.Domain)), + Key: canonEmailAddr(f.Source), + Value: strings.TrimSpace(f.Destination), + }) + } +} + +// planDefaultAddresses applies the 2B rule table to the default (catch-all) +// address section: set only onto a fresh-account destination default, +// terminal manual otherwise (a customized dest default is somebody's +// decision). +func planDefaultAddresses(plan *EmailApplyPlan, src, dest NormalizedInventory) { + if !src.DefaultAddresses.Available || !dest.DefaultAddresses.Available { + side := "source" + if src.DefaultAddresses.Available { + side = "destination" + } + plan.ManualSections = append(plan.ManualSections, EmailManualSection{ + Section: EmailSectionDefaultAddress, + Reason: fmt.Sprintf("default_address section unavailable on %s - re-run that inventory", side), + }) + return + } + + destByDomain := map[string]string{} + srcDomains := map[string]bool{} + for _, d := range dest.DefaultAddresses.Items { + destByDomain[strings.ToLower(d.Domain)] = d.DefaultAddress + } + + for _, s := range src.DefaultAddresses.Items { + domain := strings.ToLower(s.Domain) + srcDomains[domain] = true + op := EmailPlanOp{ + Section: EmailSectionDefaultAddress, + Domain: domain, + Key: domain, + SourceValue: s.DefaultAddress, + } + destVal, ok := destByDomain[domain] + op.DestinationValue = destVal + switch { + case !ok: + op.Action = EmailActionManual + op.Reason = fmt.Sprintf("domain %s is missing on the destination default-address list - create it first, then re-plan", domain) + case defaultsEquivalent(s.DefaultAddress, destVal, plan.SourceUser, plan.DestinationUser): + op.Action = EmailActionSkip + case !isFreshDefault(destVal, plan.DestinationUser): + op.Action = EmailActionManual + op.Reason = fmt.Sprintf("destination default address %q was customized on purpose - resolve by hand (terminal)", destVal) + default: + switch classifyDefaultValue(s.DefaultAddress, plan.SourceUser) { + case defaultClassAddress, defaultClassFail, defaultClassBlackhole: + op.Action = EmailActionSet + op.Value = strings.TrimSpace(s.DefaultAddress) + default: + op.Action = EmailActionManual + op.Reason = fmt.Sprintf("source default address %q cannot be round-tripped by the default-address writer - recreate by hand", s.DefaultAddress) + } + } + plan.Ops = append(plan.Ops, op) + } + + for _, d := range dest.DefaultAddresses.Items { + domain := strings.ToLower(d.Domain) + if srcDomains[domain] { + continue + } + plan.Informational = append(plan.Informational, EmailPlanInfo{ + Section: EmailSectionDefaultAddress, + Domain: domain, + Key: domain, + Value: d.DefaultAddress, + }) + } +} + +// normalizeAutoresponderBody applies the byte-verified cPanel storage +// normalization (2B-2-pre fact 5): trailing "\n" runs collapse to exactly +// one. A get_auto_responder output is already in this form; normalizing +// both sides makes the equality immune to hand-edited artifacts. +func normalizeAutoresponderBody(b string) string { + return strings.TrimRight(b, "\n") + "\n" +} + +// autoresponderCharset defaults an empty charset to the cPanel default +// and lowercases it: a "UTF-8" vs "utf-8" casing artifact across calls +// must never break equivalence (go-review 2B-2 finding 2 - worst case it +// would spuriously refuse the rollback of the tool's own create). +func autoresponderCharset(c string) string { + c = strings.ToLower(strings.TrimSpace(c)) + if c == "" { + return "utf-8" + } + return c +} + +// autorespondersEquivalent reports whether two COLLECTED autoresponders +// are behaviorally identical: every round-trippable content field, with +// the body compared under the storage normalization. +func autorespondersEquivalent(a, b NormAutoresponderEntry) bool { + return a.Subject == b.Subject && + a.From == b.From && + normalizeAutoresponderBody(a.Body) == normalizeAutoresponderBody(b.Body) && + a.IsHTML == b.IsHTML && + a.Interval == b.Interval && + a.Start == b.Start && + a.Stop == b.Stop && + autoresponderCharset(a.Charset) == autoresponderCharset(b.Charset) +} + +// planAutoresponders applies the 2B rule table to the autoresponder +// section (PR 2B-2). Bodies are collected since 2B-2, so equality is +// provable when BOTH sides carry BodyCollected; anything unprovable +// degrades to terminal manual (fail-safe). [WARN] add_auto_responder UPSERTS +// (2B-2-pre fact 7): a differing destination autoresponder is terminal +// manual - the writer never overwrites somebody's content. Dest-only +// autoresponders are informational, never deleted. +func planAutoresponders(plan *EmailApplyPlan, src, dest NormalizedInventory) { + destDomains := map[string]bool{} + for _, d := range dest.Domains { + destDomains[strings.ToLower(d.Name)] = true + } + destByEmail := map[string]NormAutoresponderEntry{} + destPresent := map[string]bool{} + for _, a := range dest.Autoresponders { + addr := canonEmailAddr(a.Email) + if !destPresent[addr] { + destPresent[addr] = true + destByEmail[addr] = a + } + } + + srcSeen := map[string]bool{} + for _, a := range src.Autoresponders { + addr := canonEmailAddr(a.Email) + if srcSeen[addr] { + continue // duplicate source rows collapse into one op + } + srcSeen[addr] = true + + op := EmailPlanOp{ + Section: EmailSectionAutoresponders, + Domain: strings.ToLower(strings.TrimSpace(a.Domain)), + Key: addr, + SourceValue: a.Subject, + } + local, domain, ok := splitEmailAddr(a.Email) + if ok { + op.Domain = strings.ToLower(domain) + } + d, onDest := destByEmail[addr] + if onDest { + op.DestinationValue = d.Subject + } + + switch { + case !ok: + op.Action = EmailActionManual + op.Reason = fmt.Sprintf("autoresponder address %q does not split into local@domain - cannot be expressed as write parameters", strings.TrimSpace(a.Email)) + case !a.BodyCollected: + op.Action = EmailActionManual + op.Reason = "the source inventory carries no autoresponder body (pre-2B-2 artifact or a failed per-address read) - re-run --account-inventory on the source, then re-plan" + case onDest && !d.BodyCollected: + op.Action = EmailActionManual + op.Reason = "an autoresponder exists on the destination but its inventory carries no body (pre-2B-2 artifact or a failed per-address read) - re-run the destination inventory, then re-plan" + case onDest && autorespondersEquivalent(a, d): + op.Action = EmailActionSkip + // The agreed content travels with the skip op too: email verify + // re-checks the destination still carries it (dnsverify + // precedent: a skip proves plan-time equality, verify proves it + // is still live). + op.Autoresponder = &EmailAutoresponderContent{ + From: a.From, + Subject: a.Subject, + Body: normalizeAutoresponderBody(a.Body), + IsHTML: a.IsHTML, + Interval: a.Interval, + Start: a.Start, + Stop: a.Stop, + Charset: autoresponderCharset(a.Charset), + } + case onDest: + op.Action = EmailActionManual + op.Reason = "an autoresponder with DIFFERENT content already exists on the destination - the writer never overwrites (the add call would destroy it); resolve by hand (terminal)" + case !destDomains[op.Domain]: + op.Action = EmailActionManual + op.Reason = fmt.Sprintf("domain %s is missing on destination - create it first, then re-plan", op.Domain) + default: + op.Action = EmailActionCreate + op.Email = strings.ToLower(local) + op.Autoresponder = &EmailAutoresponderContent{ + From: a.From, + Subject: a.Subject, + Body: normalizeAutoresponderBody(a.Body), + IsHTML: a.IsHTML, + Interval: a.Interval, + Start: a.Start, + Stop: a.Stop, + Charset: autoresponderCharset(a.Charset), + } + } + plan.Ops = append(plan.Ops, op) + } + + for _, a := range dest.Autoresponders { + addr := canonEmailAddr(a.Email) + if srcSeen[addr] { + continue + } + plan.Informational = append(plan.Informational, EmailPlanInfo{ + Section: EmailSectionAutoresponders, + Domain: strings.ToLower(strings.TrimSpace(a.Domain)), + Key: addr, + Value: a.Subject, + }) + } +} + +// filtersEquivalent reports whether two COLLECTED filters are +// content-identical: same rules (part, match, val in order) and same +// actions (action, dest in order). opt is always null (2B-3-pre fact 3) +// and is ignored. match_type is NOT round-trippable (fact 10) and is +// therefore not compared. +func filtersEquivalent(a, b NormEmailFilterEntry) bool { + if len(a.Rules) != len(b.Rules) || len(a.Actions) != len(b.Actions) { + return false + } + for i := range a.Rules { + if a.Rules[i].Part != b.Rules[i].Part || + a.Rules[i].Match != b.Rules[i].Match || + a.Rules[i].Val != b.Rules[i].Val { + return false + } + } + for i := range a.Actions { + ar, br := a.Actions[i], b.Actions[i] + if ar.Action != br.Action { + return false + } + aDest, bDest := "", "" + if ar.Dest != nil { + aDest = *ar.Dest + } + if br.Dest != nil { + bDest = *br.Dest + } + if aDest != bDest { + return false + } + } + return true +} + +// filterContentFromEntry builds the plan's EmailFilterContent from an +// inventory NormEmailFilterEntry's collected rules and actions. +func filterContentFromEntry(f NormEmailFilterEntry) *EmailFilterContent { + rules := make([]FilterRule, len(f.Rules)) + copy(rules, f.Rules) + actions := make([]FilterAction, len(f.Actions)) + for i, a := range f.Actions { + actions[i] = FilterAction{Action: a.Action} + if a.Dest != nil { + d := *a.Dest + actions[i].Dest = &d + } + } + return &EmailFilterContent{ + Rules: rules, + Actions: actions, + } +} + +// filterKey builds the deterministic plan key for a filter: +// "account/filtername" (account="" for account-level). +func filterKey(f NormEmailFilterEntry) string { + account := f.Account + if account == "" { + account = "(account-level)" + } + return account + "/" + f.FilterName +} + +// planFilters applies the 2B-3 rule table to the email filter section. +// Rules are now collected in clear (option A, 2B-3 gate). Single-rule +// filters with collected bodies -> create/skip/manual; multi-rule +// filters -> MANUAL (match_type not round-trippable, 2B-3-pre fact 10). +// [WARN] store_filter UPSERTS (fact 5): a differing destination filter is +// terminal manual - the writer never overwrites somebody's rules. +// Dest-only filters are informational, never deleted. +func planFilters(plan *EmailApplyPlan, src, dest NormalizedInventory) { + if !src.EmailFilters.Available || !dest.EmailFilters.Available { + side := "source" + if src.EmailFilters.Available { + side = "destination" + } + plan.ManualSections = append(plan.ManualSections, EmailManualSection{ + Section: EmailSectionFilters, + Reason: fmt.Sprintf("email_filters section unavailable on %s - re-run that inventory", side), + }) + return + } + + destByKey := map[string]NormEmailFilterEntry{} + destPresent := map[string]bool{} + for _, f := range dest.EmailFilters.Items { + key := filterKey(f) + if !destPresent[key] { + destPresent[key] = true + destByKey[key] = f + } + } + + srcSeen := map[string]bool{} + for _, f := range src.EmailFilters.Items { + key := filterKey(f) + if srcSeen[key] { + continue + } + srcSeen[key] = true + + op := EmailPlanOp{ + Section: EmailSectionFilters, + Key: key, + Domain: "", + SourceValue: fmt.Sprintf("%d rule(s), %d action(s), enabled=%v", f.RuleCount, f.ActionCount, f.Enabled), + } + + d, onDest := destByKey[key] + if onDest { + op.DestinationValue = fmt.Sprintf("%d rule(s), %d action(s), enabled=%v", d.RuleCount, d.ActionCount, d.Enabled) + } + + switch { + case !f.RulesCollected: + op.Action = EmailActionManual + op.Reason = "the source inventory carries no filter rules (pre-2B-3 artifact or a failed per-filter read) - re-run --account-inventory on the source, then re-plan" + case len(f.Rules) > 1: + op.Action = EmailActionManual + op.Reason = fmt.Sprintf("multi-rule filter (%d rules) - the match_type (AND/OR join) is not round-trippable (cPanel API does not return it); recreate by hand", len(f.Rules)) + case onDest && !d.RulesCollected: + op.Action = EmailActionManual + op.Reason = "a filter exists on the destination but its inventory carries no rules (pre-2B-3 artifact or a failed per-filter read) - re-run the destination inventory, then re-plan" + case onDest && filtersEquivalent(f, d): + op.Action = EmailActionSkip + op.Filter = filterContentFromEntry(f) + case onDest: + op.Action = EmailActionManual + op.Reason = "a filter with DIFFERENT content already exists on the destination - the write call upserts (never-overwrite); resolve by hand (terminal)" + default: + op.Action = EmailActionCreate + op.Filter = filterContentFromEntry(f) + op.Email = f.Account + } + plan.Ops = append(plan.Ops, op) + } + + for _, f := range dest.EmailFilters.Items { + key := filterKey(f) + if srcSeen[key] { + continue + } + plan.Informational = append(plan.Informational, EmailPlanInfo{ + Section: EmailSectionFilters, + Key: key, + Value: fmt.Sprintf("%d rule(s), %d action(s)", f.RuleCount, f.ActionCount), + }) + } +} + +// planRouting compares the configured mail-routing mode per domain: the +// enum comparison is complete, so identical values honestly skip; any +// difference is manual until the 2B-3 setmxcheck writer lands. +func planRouting(plan *EmailApplyPlan, src, dest NormalizedInventory) { + if !src.EmailRouting.Available || !dest.EmailRouting.Available { + side := "source" + if src.EmailRouting.Available { + side = "destination" + } + plan.ManualSections = append(plan.ManualSections, EmailManualSection{ + Section: EmailSectionRouting, + Reason: fmt.Sprintf("email_routing section unavailable on %s - re-run that inventory", side), + }) + return + } + destByDomain := map[string]string{} + for _, r := range dest.EmailRouting.Items { + destByDomain[strings.ToLower(r.Domain)] = r.Routing + } + for _, r := range src.EmailRouting.Items { + domain := strings.ToLower(r.Domain) + op := EmailPlanOp{ + Section: EmailSectionRouting, + Domain: domain, + Key: domain, + SourceValue: r.Routing, + } + destVal, ok := destByDomain[domain] + op.DestinationValue = destVal + switch { + case !ok: + op.Action = EmailActionManual + op.Reason = fmt.Sprintf("domain %s is missing on the destination routing list - create it first, then re-plan", domain) + case destVal == r.Routing: + op.Action = EmailActionSkip + default: + op.Action = EmailActionSet + op.Value = r.Routing + } + plan.Ops = append(plan.Ops, op) + } +} + +// emailPlanSections is the section set whose policy findings are +// cross-referenced into the plan (context for the reviewer, never a gate). +var emailPlanSections = map[string]bool{ + EmailSectionForwarders: true, + EmailSectionDefaultAddress: true, + EmailSectionAutoresponders: true, + EmailSectionFilters: true, + EmailSectionRouting: true, +} + +func emailPolicyFindings(policy *PolicyReport) []string { + if policy == nil { + return nil + } + var out []string + for _, f := range policy.Findings { + if !emailPlanSections[f.Section] { + continue + } + ref := f.SourceRef + if ref == "" { + ref = f.DestinationRef + } + if ref == "" { + ref = f.Detail + } + out = append(out, fmt.Sprintf("%s [%s] %s", f.ID, f.Severity, ref)) + } + sort.Strings(out) + return out +} + +// nonEmailBlockers lists blocker findings outside the email sections: +// they concern other migration steps and are surfaced as context only. +func nonEmailBlockers(policy *PolicyReport) []string { + if policy == nil { + return nil + } + var out []string + for _, f := range policy.Findings { + if f.Severity == SeverityBlocker && !emailPlanSections[f.Section] { + out = append(out, fmt.Sprintf("%s (%s)", f.ID, f.Section)) + } + } + sort.Strings(out) + return out +} diff --git a/internal/accountinventory/emailplan_safety_test.go b/internal/accountinventory/emailplan_safety_test.go new file mode 100644 index 00000000..d1830aba --- /dev/null +++ b/internal/accountinventory/emailplan_safety_test.go @@ -0,0 +1,66 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestEmailPlanSourcesAreOfflineByConstruction pins the read-only, +// offline invariant of the email plan-builder line of work (PR 2B-1, +// mirror of dnsplan_safety_test.go): no emailplan source may reference an +// email write primitive in code (comments may name them for +// documentation) or import a connecting package. Only the allowlisted +// writer files may name the verbs - see TestNoEmailWritePatternsModuleWide +// in internal/cpanel/email_apply_safety_test.go. +func TestEmailPlanSourcesAreOfflineByConstruction(t *testing.T) { + writeCalls := []string{ + "add_forwarder", + "set_default_address", + "delete_forwarder", + "add_auto_responder", + "delete_auto_responder", + "store_filter", + "delete_filter", + "setmxcheck", + } + forbiddenImports := []string{ + "internal/sshx", "internal/cpanel", "internal/migrate", + "golang.org/x/crypto/ssh", "\"net\"", "\"net/http\"", + } + files, err := filepath.Glob("emailplan*.go") + if err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatal("no emailplan*.go files found - glob broken?") + } + for _, f := range files { + if f == "emailplan_safety_test.go" { + continue // this file names the verbs on purpose + } + b, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(b), "\n") { + code, _, _ := strings.Cut(strings.TrimSpace(line), "//") + for _, verb := range writeCalls { + if strings.Contains(code, verb) { + t.Errorf("%s:%d references email write call %q in code - the plan line of work is read-only", + f, i+1, verb) + } + } + for _, imp := range forbiddenImports { + if strings.Contains(code, imp) { + t.Errorf("%s:%d imports/references %q - the plan builder is offline by construction", + f, i+1, imp) + } + } + } + } +} diff --git a/internal/accountinventory/emailplan_test.go b/internal/accountinventory/emailplan_test.go new file mode 100644 index 00000000..6f790c3b --- /dev/null +++ b/internal/accountinventory/emailplan_test.go @@ -0,0 +1,735 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "strings" + "testing" +) + +// --- fixtures --------------------------------------------------------------- + +// epInventory builds a minimal inventory for email-plan tests: one main +// domain, available default-address / routing / filter sections. +func epInventory(side, user, domain string) NormalizedInventory { + inv := NewEmptyInventory(user, "192.0.2.1", side) + inv.Domains = []DomainEntry{{Name: domain, Type: "main"}} + inv.DefaultAddresses.Available = true + inv.DefaultAddresses.Items = []DefaultAddressEntry{{Domain: domain, DefaultAddress: user}} + inv.EmailRouting.Available = true + inv.EmailRouting.Items = []EmailRoutingEntry{{Domain: domain, Routing: "local"}} + inv.EmailFilters.Available = true + return inv +} + +func opsBySection(p EmailApplyPlan, section string) []EmailPlanOp { + var out []EmailPlanOp + for _, op := range p.Ops { + if op.Section == section { + out = append(out, op) + } + } + return out +} + +func findEmailOp(t *testing.T, p EmailApplyPlan, section, key string) EmailPlanOp { + t.Helper() + for _, op := range p.Ops { + if op.Section == section && op.Key == key { + return op + } + } + t.Fatalf("no op for section %q key %q in %+v", section, key, p.Ops) + return EmailPlanOp{} +} + +// --- forwarders ------------------------------------------------------------- + +func TestEmailPlanForwarderCreate(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Forwarders = []NormForwarderEntry{ + {Source: "info@example.com", Destination: "someone@gmail.com", Domain: "example.com"}, + } + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + + op := findEmailOp(t, p, "forwarders", "info@example.com") + if op.Action != EmailActionCreate { + t.Fatalf("action = %q, want create (reason %q)", op.Action, op.Reason) + } + if op.Domain != "example.com" || op.Email != "info" || op.Forward != "someone@gmail.com" { + t.Errorf("write fields = domain %q email %q forward %q", op.Domain, op.Email, op.Forward) + } + if len(op.PlanTimeDestForwards) != 0 { + t.Errorf("plan-time dest forwards = %v, want empty (fresh dest)", op.PlanTimeDestForwards) + } + if p.Summary.Create != 1 { + t.Errorf("summary.create = %d, want 1", p.Summary.Create) + } +} + +func TestEmailPlanForwarderSkipWhenPairPresent(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Forwarders = []NormForwarderEntry{ + {Source: "info@example.com", Destination: "someone@gmail.com", Domain: "example.com"}, + } + dest := epInventory("destination", "acct", "example.com") + // Same pair, different spelling: canonical comparison must match. + dest.Forwarders = []NormForwarderEntry{ + {Source: "INFO@Example.COM", Destination: "Someone@Gmail.com", Domain: "example.com"}, + } + + p := BuildEmailPlan(src, dest, nil) + + op := findEmailOp(t, p, "forwarders", "info@example.com") + if op.Action != EmailActionSkip { + t.Fatalf("action = %q, want skip (reason %q)", op.Action, op.Reason) + } + if p.Summary.Skip == 0 { + t.Errorf("summary.skip = %d, want > 0", p.Summary.Skip) + } +} + +// Non-single-address forward targets are terminal manual: comma-joined +// multi-target, pipes, file paths, :fail:/:blackhole:, deliver-to-account. +func TestEmailPlanForwarderManualForms(t *testing.T) { + cases := []struct { + name string + target string + }{ + {"multi-target comma", "sales@company.com, backup@company.com"}, + {"pipe to script", "|/home/acct/script.sh"}, + {"deliver to file", "/home/acct/mail/archive"}, + {"system fail", ":fail: No Such User Here"}, + {"system blackhole", ":blackhole:"}, + {"deliver to account", "otheracct"}, + {"two at signs", "a@b@example.com"}, + {"domain without dot", "user@localhost"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Forwarders = []NormForwarderEntry{ + {Source: "info@example.com", Destination: tc.target, Domain: "example.com"}, + } + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "forwarders", "info@example.com") + if op.Action != EmailActionManual { + t.Fatalf("target %q: action = %q, want manual", tc.target, op.Action) + } + if !strings.Contains(op.Reason, tc.target) && !strings.Contains(op.Reason, strings.TrimSpace(tc.target)) { + t.Errorf("reason %q does not carry the raw value %q", op.Reason, tc.target) + } + }) + } +} + +func TestEmailPlanForwarderManualWhenSourceAddressMalformed(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Forwarders = []NormForwarderEntry{ + {Source: "not-an-address", Destination: "x@gmail.com", Domain: "example.com"}, + } + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "forwarders", "not-an-address") + if op.Action != EmailActionManual { + t.Fatalf("action = %q, want manual", op.Action) + } +} + +func TestEmailPlanForwarderManualWhenDomainMissingOnDest(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Forwarders = []NormForwarderEntry{ + {Source: "info@other.com", Destination: "x@gmail.com", Domain: "other.com"}, + } + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "forwarders", "info@other.com") + if op.Action != EmailActionManual { + t.Fatalf("action = %q, want manual (domain missing on dest)", op.Action) + } + if !strings.Contains(op.Reason, "missing on destination") { + t.Errorf("reason = %q", op.Reason) + } +} + +// A create op for an address the destination already forwards ELSEWHERE +// still plans (additive posture, dest-only pair stays informational), but +// records the plan-time dest targets as its precondition. +func TestEmailPlanForwarderCreateRecordsPlanTimeDestState(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Forwarders = []NormForwarderEntry{ + {Source: "info@example.com", Destination: "new@gmail.com", Domain: "example.com"}, + } + dest := epInventory("destination", "acct", "example.com") + dest.Forwarders = []NormForwarderEntry{ + {Source: "info@example.com", Destination: "old@gmail.com", Domain: "example.com"}, + } + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "forwarders", "info@example.com") + if op.Action != EmailActionCreate { + t.Fatalf("action = %q, want create", op.Action) + } + if len(op.PlanTimeDestForwards) != 1 || op.PlanTimeDestForwards[0] != "old@gmail.com" { + t.Errorf("plan-time dest forwards = %v, want [old@gmail.com]", op.PlanTimeDestForwards) + } + // The dest-only pair is informational, never deleted. + if len(p.Informational) != 1 || p.Informational[0].Section != "forwarders" { + t.Errorf("informational = %+v, want the dest-only pair", p.Informational) + } +} + +func TestEmailPlanDestOnlyForwardersAreInformational(t *testing.T) { + src := epInventory("source", "acct", "example.com") + dest := epInventory("destination", "acct", "example.com") + dest.Forwarders = []NormForwarderEntry{ + {Source: "sales@example.com", Destination: "x@gmail.com", Domain: "example.com"}, + } + + p := BuildEmailPlan(src, dest, nil) + if len(opsBySection(p, "forwarders")) != 0 { + t.Errorf("no forwarder ops expected, got %+v", p.Ops) + } + if len(p.Informational) != 1 || p.Informational[0].Key != "sales@example.com" { + t.Errorf("informational = %+v", p.Informational) + } + if p.Summary.Informational != 1 { + t.Errorf("summary.informational = %d, want 1", p.Summary.Informational) + } +} + +// Duplicate source rows for the same canonical pair collapse into one op. +func TestEmailPlanForwarderDeduplicatesIdenticalPairs(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Forwarders = []NormForwarderEntry{ + {Source: "info@example.com", Destination: "x@gmail.com", Domain: "example.com"}, + {Source: "INFO@example.com", Destination: "X@GMAIL.com", Domain: "example.com"}, + } + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + if got := len(opsBySection(p, "forwarders")); got != 1 { + t.Errorf("forwarder ops = %d, want 1 (deduplicated)", got) + } +} + +// --- default address -------------------------------------------------------- + +func TestEmailPlanDefaultAddressSetOnFreshDest(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.DefaultAddresses.Items = []DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "someone@gmail.com"}, + } + dest := epInventory("destination", "acct", "example.com") // fresh default = username "acct" + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "default_address", "example.com") + if op.Action != EmailActionSet { + t.Fatalf("action = %q, want set (reason %q)", op.Action, op.Reason) + } + if op.Value != "someone@gmail.com" || op.DestinationValue != "acct" { + t.Errorf("value = %q, destination_value = %q", op.Value, op.DestinationValue) + } + if p.Summary.Set != 1 { + t.Errorf("summary.set = %d, want 1", p.Summary.Set) + } +} + +func TestEmailPlanDefaultAddressSetOnFreshFailDest(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.DefaultAddresses.Items = []DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "someone@gmail.com"}, + } + dest := epInventory("destination", "acct", "example.com") + dest.DefaultAddresses.Items = []DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: ":fail: No Such User Here"}, + } + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "default_address", "example.com") + if op.Action != EmailActionSet { + t.Fatalf("action = %q, want set (:fail: prefix is a fresh-account form)", op.Action) + } +} + +func TestEmailPlanDefaultAddressSkipCases(t *testing.T) { + cases := []struct { + name string + srcUser string + srcVal string + destUser string + destVal string + }{ + {"identical address", "acct", "someone@gmail.com", "acct", "someone@gmail.com"}, + {"both account defaults", "srcacct", "srcacct", "destacct", "destacct"}, + {"both fail forms, locale-different tails", "acct", ":fail: No Such User Here", "acct", ":fail: no such address here"}, + {"both blackhole forms", "acct", ":blackhole:", "acct", ":blackhole: discarded"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src := epInventory("source", tc.srcUser, "example.com") + src.DefaultAddresses.Items = []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: tc.srcVal}} + dest := epInventory("destination", tc.destUser, "example.com") + dest.DefaultAddresses.Items = []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: tc.destVal}} + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "default_address", "example.com") + if op.Action != EmailActionSkip { + t.Fatalf("action = %q, want skip (reason %q)", op.Action, op.Reason) + } + }) + } +} + +// A destination default that is neither identical nor a fresh-account form +// is somebody's decision: terminal manual, never overwritten. +func TestEmailPlanDefaultAddressManualWhenDestCustomized(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.DefaultAddresses.Items = []DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "someone@gmail.com"}, + } + dest := epInventory("destination", "acct", "example.com") + dest.DefaultAddresses.Items = []DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "other@custom.example"}, + } + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "default_address", "example.com") + if op.Action != EmailActionManual { + t.Fatalf("action = %q, want manual", op.Action) + } +} + +func TestEmailPlanDefaultAddressManualWhenDomainMissingOnDest(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.DefaultAddresses.Items = []DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "someone@gmail.com"}, + {Domain: "other.com", DefaultAddress: "someone@gmail.com"}, + } + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "default_address", "other.com") + if op.Action != EmailActionManual { + t.Fatalf("action = %q, want manual", op.Action) + } +} + +// A source default the writer cannot round-trip onto a fresh dest +// (pipes, bare non-username values) is manual, not set. +func TestEmailPlanDefaultAddressManualUnroundtrippableSource(t *testing.T) { + cases := []string{"|/home/acct/script.sh", "someotheruser", "a, b@c.com"} + for _, srcVal := range cases { + src := epInventory("source", "acct", "example.com") + src.DefaultAddresses.Items = []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: srcVal}} + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "default_address", "example.com") + if op.Action != EmailActionManual { + t.Errorf("src %q: action = %q, want manual", srcVal, op.Action) + } + } +} + +// Migrating a :fail:/:blackhole: source onto a fresh username dest is a +// real behavior change the writer can express: it plans as set. +func TestEmailPlanDefaultAddressSetSystemFormOntoFreshUsername(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.DefaultAddresses.Items = []DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: ":fail: No Such User Here"}, + } + dest := epInventory("destination", "acct", "example.com") // fresh = "acct" + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "default_address", "example.com") + if op.Action != EmailActionSet { + t.Fatalf("action = %q, want set (reason %q)", op.Action, op.Reason) + } + if op.Value != ":fail: No Such User Here" { + t.Errorf("value = %q", op.Value) + } +} + +func TestEmailPlanDefaultAddressSectionUnavailable(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.DefaultAddresses.Items = []DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "someone@gmail.com"}, + } + dest := epInventory("destination", "acct", "example.com") + dest.DefaultAddresses.Available = false + dest.DefaultAddresses.Items = nil + + p := BuildEmailPlan(src, dest, nil) + if len(opsBySection(p, "default_address")) != 0 { + t.Errorf("no default_address ops expected when the section is unavailable, got %+v", p.Ops) + } + var found bool + for _, ms := range p.ManualSections { + if ms.Section == "default_address" { + found = true + } + } + if !found { + t.Errorf("manual_sections = %+v, want default_address listed", p.ManualSections) + } +} + +// --- 2B-2/2B-3 sections carried as manual from day one ---------------------- + +// srcAutoresponder returns a fully-collected source autoresponder entry. +func srcAutoresponder(addr, domain string) NormAutoresponderEntry { + return NormAutoresponderEntry{ + Email: addr, Domain: domain, + Subject: "Out of office", From: "Info Desk", + Body: "On vacation.\nBack Monday.\n", + IsHTML: 0, Interval: 8, Start: 0, Stop: 0, Charset: "utf-8", + BodyCollected: true, + } +} + +func TestEmailPlanAutoresponderCreate(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Autoresponders = []NormAutoresponderEntry{srcAutoresponder("info@example.com", "example.com")} + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "autoresponders", "info@example.com") + if op.Action != EmailActionCreate { + t.Fatalf("action = %q (reason %q), want create", op.Action, op.Reason) + } + if op.Email != "info" || op.Domain != "example.com" { + t.Errorf("write params local/domain = %q/%q", op.Email, op.Domain) + } + if op.Autoresponder == nil { + t.Fatal("create op must carry the full autoresponder content payload") + } + a := op.Autoresponder + if a.Body != "On vacation.\nBack Monday.\n" || a.Subject != "Out of office" || + a.From != "Info Desk" || a.Interval != 8 || a.IsHTML != 0 || a.Charset != "utf-8" { + t.Errorf("payload = %+v", a) + } + if p.Summary.Create != 1 { + t.Errorf("summary create = %d, want 1", p.Summary.Create) + } +} + +func TestEmailPlanAutoresponderSkipWhenEquivalent(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Autoresponders = []NormAutoresponderEntry{srcAutoresponder("info@example.com", "example.com")} + dest := epInventory("destination", "acct", "example.com") + d := srcAutoresponder("info@example.com", "example.com") + // cPanel normalizes trailing newline runs to exactly one (2B-2-pre + // fact 5): a dest body differing only there is behaviorally identical. + d.Body = "On vacation.\nBack Monday.\n\n\n" + dest.Autoresponders = []NormAutoresponderEntry{d} + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "autoresponders", "info@example.com") + if op.Action != EmailActionSkip { + t.Fatalf("action = %q (reason %q), want skip", op.Action, op.Reason) + } +} + +func TestEmailPlanAutoresponderManualWhenDestDiffers(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Autoresponders = []NormAutoresponderEntry{srcAutoresponder("info@example.com", "example.com")} + dest := epInventory("destination", "acct", "example.com") + d := srcAutoresponder("info@example.com", "example.com") + d.Body = "I am on vacation.\n" + dest.Autoresponders = []NormAutoresponderEntry{d} + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "autoresponders", "info@example.com") + if op.Action != EmailActionManual { + t.Fatalf("action = %q, want manual (the writer must never overwrite)", op.Action) + } + if !strings.Contains(op.Reason, "overwrite") { + t.Errorf("reason %q should explain the never-overwrite refusal", op.Reason) + } +} + +func TestEmailPlanAutoresponderManualWhenBodyNotCollected(t *testing.T) { + // Source entry without BodyCollected (pre-2B-2 artifact or failed get): + // no equality can be proven, no create payload would be faithful. + src := epInventory("source", "acct", "example.com") + src.Autoresponders = []NormAutoresponderEntry{ + {Email: "info@example.com", Domain: "example.com", Subject: "OOO", Interval: 8}, + } + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "autoresponders", "info@example.com") + if op.Action != EmailActionManual { + t.Fatalf("action = %q, want manual", op.Action) + } + if !strings.Contains(op.Reason, "re-run") { + t.Errorf("reason %q should tell the operator to re-run the inventory", op.Reason) + } + + // Dest entry without BodyCollected while the address exists there: + // equality unprovable -> manual, fail-safe. + src2 := epInventory("source", "acct", "example.com") + src2.Autoresponders = []NormAutoresponderEntry{srcAutoresponder("info@example.com", "example.com")} + dest2 := epInventory("destination", "acct", "example.com") + dest2.Autoresponders = []NormAutoresponderEntry{ + {Email: "info@example.com", Domain: "example.com", Subject: "Out of office", Interval: 8}, + } + p2 := BuildEmailPlan(src2, dest2, nil) + op2 := findEmailOp(t, p2, "autoresponders", "info@example.com") + if op2.Action != EmailActionManual { + t.Fatalf("dest-uncollected action = %q, want manual", op2.Action) + } +} + +func TestEmailPlanAutoresponderManualWhenDomainMissingOnDest(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Autoresponders = []NormAutoresponderEntry{srcAutoresponder("info@other.example", "other.example")} + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "autoresponders", "info@other.example") + if op.Action != EmailActionManual { + t.Fatalf("action = %q, want manual", op.Action) + } + if !strings.Contains(op.Reason, "missing on destination") { + t.Errorf("reason %q", op.Reason) + } +} + +func TestEmailPlanAutoresponderMalformedAddressIsManual(t *testing.T) { + src := epInventory("source", "acct", "example.com") + a := srcAutoresponder("not-an-address", "example.com") + a.Email = "not-an-address" + src.Autoresponders = []NormAutoresponderEntry{a} + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "autoresponders", "not-an-address") + if op.Action != EmailActionManual { + t.Fatalf("action = %q, want manual", op.Action) + } +} + +func TestEmailPlanDestOnlyAutorespondersAreInformational(t *testing.T) { + src := epInventory("source", "acct", "example.com") + dest := epInventory("destination", "acct", "example.com") + dest.Autoresponders = []NormAutoresponderEntry{srcAutoresponder("only-dest@example.com", "example.com")} + + p := BuildEmailPlan(src, dest, nil) + if len(opsBySection(p, "autoresponders")) != 0 { + t.Fatalf("dest-only autoresponders must produce no ops: %+v", opsBySection(p, "autoresponders")) + } + found := false + for _, info := range p.Informational { + if info.Section == "autoresponders" && info.Key == "only-dest@example.com" { + found = true + } + } + if !found { + t.Errorf("dest-only autoresponder missing from informational: %+v", p.Informational) + } +} + +func TestEmailPlanFiltersSingleRuleCreate(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.EmailFilters.Items = []NormEmailFilterEntry{ + {Account: "", FilterName: "simple", Enabled: true, RuleCount: 1, ActionCount: 1, + Rules: []FilterRule{{Part: "$header_From:", Match: "contains", Val: "spam@x.com"}}, + Actions: []FilterAction{{Action: "fail"}}, + RulesCollected: true}, + } + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "email_filters", "(account-level)/simple") + if op.Action != EmailActionCreate { + t.Fatalf("single-rule filter: action = %q, want create (reason %q)", op.Action, op.Reason) + } + if op.Filter == nil || len(op.Filter.Rules) != 1 { + t.Fatalf("filter content missing or wrong rule count") + } +} + +func TestEmailPlanFiltersMultiRuleManual(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.EmailFilters.Items = []NormEmailFilterEntry{ + {Account: "", FilterName: "multi", Enabled: true, RuleCount: 2, ActionCount: 1, + Rules: []FilterRule{ + {Part: "$header_From:", Match: "contains", Val: "a@x.com"}, + {Part: "$header_Subject:", Match: "contains", Val: "SPAM"}, + }, + Actions: []FilterAction{{Action: "fail"}}, + RulesCollected: true}, + } + dest := epInventory("destination", "acct", "example.com") + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "email_filters", "(account-level)/multi") + if op.Action != EmailActionManual { + t.Fatalf("multi-rule filter: action = %q, want manual", op.Action) + } + if !strings.Contains(op.Reason, "match_type") { + t.Errorf("reason %q should mention match_type", op.Reason) + } +} + +func TestEmailPlanFiltersIdenticalSkip(t *testing.T) { + rule := FilterRule{Part: "$header_From:", Match: "contains", Val: "spam@x.com"} + action := FilterAction{Action: "fail"} + src := epInventory("source", "acct", "example.com") + src.EmailFilters.Items = []NormEmailFilterEntry{ + {Account: "", FilterName: "same", Enabled: true, RuleCount: 1, ActionCount: 1, + Rules: []FilterRule{rule}, Actions: []FilterAction{action}, RulesCollected: true}, + } + dest := epInventory("destination", "acct", "example.com") + dest.EmailFilters.Items = []NormEmailFilterEntry{ + {Account: "", FilterName: "same", Enabled: true, RuleCount: 1, ActionCount: 1, + Rules: []FilterRule{rule}, Actions: []FilterAction{action}, RulesCollected: true}, + } + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "email_filters", "(account-level)/same") + if op.Action != EmailActionSkip { + t.Fatalf("identical filter: action = %q, want skip", op.Action) + } +} + +func TestEmailPlanFiltersDifferentDestManual(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.EmailFilters.Items = []NormEmailFilterEntry{ + {Account: "", FilterName: "diff", Enabled: true, RuleCount: 1, ActionCount: 1, + Rules: []FilterRule{{Part: "$header_From:", Match: "contains", Val: "spam@x.com"}}, + Actions: []FilterAction{{Action: "fail"}}, + RulesCollected: true}, + } + dest := epInventory("destination", "acct", "example.com") + dest.EmailFilters.Items = []NormEmailFilterEntry{ + {Account: "", FilterName: "diff", Enabled: true, RuleCount: 1, ActionCount: 1, + Rules: []FilterRule{{Part: "$header_To:", Match: "is", Val: "other@x.com"}}, + Actions: []FilterAction{{Action: "finish"}}, + RulesCollected: true}, + } + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "email_filters", "(account-level)/diff") + if op.Action != EmailActionManual { + t.Fatalf("different filter: action = %q, want manual", op.Action) + } +} + +func TestEmailPlanRoutingSkipWhenIdenticalSetWhenNot(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.EmailRouting.Items = []EmailRoutingEntry{{Domain: "example.com", Routing: "remote"}} + dest := epInventory("destination", "acct", "example.com") + dest.EmailRouting.Items = []EmailRoutingEntry{{Domain: "example.com", Routing: "local"}} + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "email_routing", "example.com") + if op.Action != EmailActionSet { + t.Fatalf("differing routing: action = %q, want set (reason %q)", op.Action, op.Reason) + } + if op.Value != "remote" { + t.Errorf("set value = %q, want remote", op.Value) + } + + dest.EmailRouting.Items = []EmailRoutingEntry{{Domain: "example.com", Routing: "remote"}} + p = BuildEmailPlan(src, dest, nil) + op = findEmailOp(t, p, "email_routing", "example.com") + if op.Action != EmailActionSkip { + t.Fatalf("identical routing: action = %q, want skip", op.Action) + } +} + +// --- plan envelope ---------------------------------------------------------- + +func TestEmailPlanEnvelopeAndDeterminism(t *testing.T) { + src := epInventory("source", "srcacct", "example.com") + src.Forwarders = []NormForwarderEntry{ + {Source: "zeta@example.com", Destination: "z@gmail.com", Domain: "example.com"}, + {Source: "alpha@example.com", Destination: "a@gmail.com", Domain: "example.com"}, + } + dest := epInventory("destination", "destacct", "example.com") + + p1 := BuildEmailPlan(src, dest, nil) + p2 := BuildEmailPlan(src, dest, nil) + + if p1.Mode != "email-apply-plan" || p1.FormatVersion != 1 { + t.Errorf("mode = %q, format_version = %d", p1.Mode, p1.FormatVersion) + } + if p1.SourceUser != "srcacct" || p1.DestinationUser != "destacct" { + t.Errorf("users = %q/%q", p1.SourceUser, p1.DestinationUser) + } + if len(p1.Ops) != len(p2.Ops) { + t.Fatalf("non-deterministic op count") + } + for i := range p1.Ops { + if p1.Ops[i].Section != p2.Ops[i].Section || p1.Ops[i].Key != p2.Ops[i].Key { + t.Fatalf("non-deterministic op order at %d", i) + } + } + fw := opsBySection(p1, "forwarders") + if len(fw) != 2 || fw[0].Key > fw[1].Key { + t.Errorf("forwarder ops not sorted by key: %+v", fw) + } + // Ops must be array-typed even when empty (diff.go convention). + empty := BuildEmailPlan(epInventory("source", "a", "x.com"), epInventory("destination", "a", "x.com"), nil) + if empty.Ops == nil { + t.Errorf("Ops must be non-nil") + } +} + +func TestEmailPlanPolicyContext(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Forwarders = []NormForwarderEntry{ + {Source: "info@example.com", Destination: "x@gmail.com", Domain: "example.com"}, + } + dest := epInventory("destination", "acct", "example.com") + policy := &PolicyReport{ + Findings: []PolicyFinding{ + {ID: "POL-FORWARDER-REMOVED", Section: "forwarders", Severity: SeverityReview, SourceRef: "info@example.com -> x@gmail.com"}, + {ID: "POL-DB-REMOVED", Section: "databases", Severity: SeverityBlocker}, + {ID: "POL-PHP-CHANGED", Section: "php", Severity: SeverityReview}, + }, + } + + p := BuildEmailPlan(src, dest, policy) + if len(p.PolicyFindings) != 1 || !strings.Contains(p.PolicyFindings[0], "POL-FORWARDER-REMOVED") { + t.Errorf("policy_findings = %v", p.PolicyFindings) + } + if len(p.NonEmailBlockers) != 1 || !strings.Contains(p.NonEmailBlockers[0], "POL-DB-REMOVED") { + t.Errorf("non_email_blockers = %v", p.NonEmailBlockers) + } + // Policy is context, never a gate: the create op is still there. + if op := findEmailOp(t, p, "forwarders", "info@example.com"); op.Action != EmailActionCreate { + t.Errorf("policy must never gate: action = %q", op.Action) + } +} + +// go-review 2B-2 finding 2 (MEDIUM): charset equality must be +// case-insensitive - a "UTF-8" vs "utf-8" casing artifact across calls +// must never break equivalence (worst case it would spuriously refuse the +// rollback of the tool's own create). +func TestEmailPlanAutoresponderCharsetCaseInsensitive(t *testing.T) { + src := epInventory("source", "acct", "example.com") + a := srcAutoresponder("info@example.com", "example.com") + a.Charset = "UTF-8" + src.Autoresponders = []NormAutoresponderEntry{a} + dest := epInventory("destination", "acct", "example.com") + d := srcAutoresponder("info@example.com", "example.com") + d.Charset = "utf-8" + dest.Autoresponders = []NormAutoresponderEntry{d} + + p := BuildEmailPlan(src, dest, nil) + op := findEmailOp(t, p, "autoresponders", "info@example.com") + if op.Action != EmailActionSkip { + t.Fatalf("action = %q (reason %q), want skip (charset differs only by case)", op.Action, op.Reason) + } +} diff --git a/internal/accountinventory/emailplan_write.go b/internal/accountinventory/emailplan_write.go new file mode 100644 index 00000000..71bd2c7d --- /dev/null +++ b/internal/accountinventory/emailplan_write.go @@ -0,0 +1,150 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// WriteEmailPlanJSON writes the machine-readable email apply plan. +func WriteEmailPlanJSON(path string, p EmailApplyPlan) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + b, err := json.MarshalIndent(p, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal email plan: %w", err) + } + b = append(b, '\n') + return os.WriteFile(path, b, 0o600) +} + +// WriteEmailPlanMarkdown writes the human-readable plan. Manual items are +// listed first: they are the ones requiring operator work before an apply +// is meaningful. The fresh-default assumption is carried verbatim (design +// rule). Cells go through mdCell. +func WriteEmailPlanMarkdown(path string, p EmailApplyPlan) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + + fmt.Fprintf(&sb, "# Email Apply Plan\n\n") + fmt.Fprintf(&sb, "- **Source**: %s (sha256 %s)\n", p.SourceFile, p.SourceSHA256) + fmt.Fprintf(&sb, "- **Destination**: %s (sha256 %s)\n", p.DestinationFile, p.DestinationSHA256) + if p.PolicyFile != "" { + fmt.Fprintf(&sb, "- **Policy context**: %s\n", p.PolicyFile) + } + fmt.Fprintf(&sb, "- **Accounts**: source `%s` -> destination `%s`\n", p.SourceUser, p.DestinationUser) + fmt.Fprintf(&sb, "- **Generated**: %s\n\n", p.GeneratedAt) + fmt.Fprintf(&sb, "**Summary**: %d create, %d set, %d skip, %d manual, %d informational\n\n", + p.Summary.Create, p.Summary.Set, p.Summary.Skip, p.Summary.Manual, p.Summary.Informational) + sb.WriteString("The plan never deletes destination-only resources; `manual` items are never applied and have no override.\n\n") + fmt.Fprintf(&sb, "> %s\n\n", freshDefaultAssumption) + + for _, b := range p.NonEmailBlockers { + fmt.Fprintf(&sb, "> **Non-email blocker (context only)**: %s\n", b) + } + if len(p.NonEmailBlockers) > 0 { + sb.WriteString("\n") + } + for _, f := range p.PolicyFindings { + fmt.Fprintf(&sb, "> Policy: %s\n", f) + } + if len(p.PolicyFindings) > 0 { + sb.WriteString("\n") + } + + if len(p.ManualSections) > 0 { + sb.WriteString("## Sections excluded from the plan\n\n") + sb.WriteString("| Section | Reason |\n|---------|--------|\n") + for _, ms := range p.ManualSections { + fmt.Fprintf(&sb, "| %s | %s |\n", mdCell(ms.Section, 30), mdCell(ms.Reason, 100)) + } + sb.WriteString("\n") + } + + if len(p.Ops) == 0 && len(p.Informational) == 0 { + sb.WriteString("No email-config items to plan.\n") + return os.WriteFile(path, []byte(sb.String()), 0o600) + } + + for _, action := range []string{EmailActionManual, EmailActionCreate, EmailActionSet, EmailActionSkip} { + rows := emailOpsByAction(p.Ops, action) + if len(rows) == 0 { + continue + } + fmt.Fprintf(&sb, "## %s (%d)\n\n", strings.ToUpper(action), len(rows)) + sb.WriteString("| Section | Key | Desired / Reason | Destination now |\n") + sb.WriteString("|---------|-----|------------------|------------------|\n") + for _, op := range rows { + detail := op.Reason + if detail == "" { + detail = emailOpDesired(op) + } + fmt.Fprintf(&sb, "| %s | %s | %s | %s |\n", + mdCell(op.Section, 20), mdCell(op.Key, 60), mdCell(detail, 110), + mdCell(op.DestinationValue, 60)) + } + sb.WriteString("\n") + } + + if len(p.Informational) > 0 { + fmt.Fprintf(&sb, "## Destination-only items (%d, never deleted)\n\n", len(p.Informational)) + sb.WriteString("| Section | Key | Value |\n|---------|-----|-------|\n") + for _, info := range p.Informational { + fmt.Fprintf(&sb, "| %s | %s | %s |\n", + mdCell(info.Section, 20), mdCell(info.Key, 60), mdCell(info.Value, 80)) + } + sb.WriteString("\n") + } + + return os.WriteFile(path, []byte(sb.String()), 0o600) +} + +func emailOpsByAction(ops []EmailPlanOp, action string) []EmailPlanOp { + var out []EmailPlanOp + for _, op := range ops { + if op.Action == action { + out = append(out, op) + } + } + return out +} + +// emailOpDesired renders what an actionable op will write. +func emailOpDesired(op EmailPlanOp) string { + switch op.Action { + case EmailActionCreate: + if op.Section == EmailSectionAutoresponders && op.Autoresponder != nil { + a := op.Autoresponder + html := "plain" + if a.IsHTML != 0 { + html = "html" + } + return fmt.Sprintf("autoresponder %s@%s - subject %q, %s, every %dh, %d body byte(s)", + op.Email, op.Domain, a.Subject, html, a.Interval, len(a.Body)) + } + if op.Section == EmailSectionFilters && op.Filter != nil { + return fmt.Sprintf("filter %s - %d rule(s), %d action(s)", + op.Key, len(op.Filter.Rules), len(op.Filter.Actions)) + } + return fmt.Sprintf("forward %s@%s -> %s", op.Email, op.Domain, op.Forward) + case EmailActionSet: + if op.Section == EmailSectionRouting { + return fmt.Sprintf("routing -> %s", op.Value) + } + return fmt.Sprintf("default address -> %s", op.Value) + case EmailActionSkip: + if op.SourceValue != "" { + return fmt.Sprintf("already satisfied (%s)", op.SourceValue) + } + return "already satisfied" + } + return op.SourceValue +} diff --git a/internal/accountinventory/emailplan_write_test.go b/internal/accountinventory/emailplan_write_test.go new file mode 100644 index 00000000..4446448d --- /dev/null +++ b/internal/accountinventory/emailplan_write_test.go @@ -0,0 +1,129 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func epTestPlan() EmailApplyPlan { + src := epInventory("source", "srcacct", "example.com") + src.Forwarders = []NormForwarderEntry{ + {Source: "info@example.com", Destination: "someone@gmail.com", Domain: "example.com"}, + {Source: "multi@example.com", Destination: "a@x.com, b@y.com", Domain: "example.com"}, + } + src.DefaultAddresses.Items = []DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "someone@gmail.com"}, + } + dest := epInventory("destination", "destacct", "example.com") + dest.Forwarders = []NormForwarderEntry{ + {Source: "old@example.com", Destination: "keep@z.com", Domain: "example.com"}, + } + dest.DefaultAddresses.Items = []DefaultAddressEntry{ + {Domain: "example.com", DefaultAddress: "destacct"}, + } + p := BuildEmailPlan(src, dest, nil) + p.GeneratedAt = "2026-07-03T00:00:00Z" + p.SourceFile, p.SourceSHA256 = "src.json", "aaaa" + p.DestinationFile, p.DestinationSHA256 = "dest.json", "bbbb" + return p +} + +func TestWriteEmailPlanJSONRoundTrip(t *testing.T) { + dir := t.TempDir() + p := epTestPlan() + path := filepath.Join(dir, "email_apply_plan.json") + if err := WriteEmailPlanJSON(path, p); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var got EmailApplyPlan + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if got.Mode != "email-apply-plan" || got.FormatVersion != 1 { + t.Errorf("mode/version = %q/%d", got.Mode, got.FormatVersion) + } + if len(got.Ops) != len(p.Ops) { + t.Errorf("ops = %d, want %d", len(got.Ops), len(p.Ops)) + } + if got.Summary != p.Summary { + t.Errorf("summary = %+v, want %+v", got.Summary, p.Summary) + } + // The file must be mode 0600 (contains addresses). + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("file mode = %v, want 0600", info.Mode().Perm()) + } +} + +func TestWriteEmailPlanMarkdown(t *testing.T) { + dir := t.TempDir() + p := epTestPlan() + path := filepath.Join(dir, "email_apply_plan.md") + if err := WriteEmailPlanMarkdown(path, p); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + md := string(b) + + // The fresh-default assumption travels verbatim (design rule). + if !strings.Contains(md, freshDefaultAssumption) { + t.Error("markdown does not carry the fresh-default assumption verbatim") + } + // Manual section comes before create (manual-first ordering). + iManual := strings.Index(md, "## MANUAL") + iCreate := strings.Index(md, "## CREATE") + if iManual < 0 || iCreate < 0 || iManual > iCreate { + t.Errorf("manual-first ordering broken: manual@%d create@%d", iManual, iCreate) + } + if !strings.Contains(md, "## SET") { + t.Error("missing SET section") + } + if !strings.Contains(md, "Destination-only items") { + t.Error("missing destination-only informational section") + } + if !strings.Contains(md, "never deletes destination-only resources") { + t.Error("missing never-delete statement") + } +} + +// PR 2B-2: an autoresponder create op renders its own description, not +// the forwarder shape. +func TestEmailPlanMarkdownAutoresponderCreate(t *testing.T) { + src := epInventory("source", "acct", "example.com") + src.Autoresponders = []NormAutoresponderEntry{srcAutoresponder("info@example.com", "example.com")} + dest := epInventory("destination", "acct", "example.com") + p := BuildEmailPlan(src, dest, nil) + + dir := t.TempDir() + path := filepath.Join(dir, "plan.md") + if err := WriteEmailPlanMarkdown(path, p); err != nil { + t.Fatalf("WriteEmailPlanMarkdown: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := string(b) + if !strings.Contains(s, "autoresponder") || !strings.Contains(s, "Out of office") { + t.Errorf("markdown should describe the autoresponder create, got:\n%s", s) + } + if strings.Contains(s, "forward info@example.com") { + t.Errorf("autoresponder create rendered with the forwarder shape:\n%s", s) + } +} diff --git a/internal/accountinventory/emailverify.go b/internal/accountinventory/emailverify.go new file mode 100644 index 00000000..94ca573c --- /dev/null +++ b/internal/accountinventory/emailverify.go @@ -0,0 +1,480 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "fmt" + "sort" + "strings" +) + +// Email verify (PR 2B-1). VerifyEmailPlan is fully offline: it consumes +// an email apply plan and the live DESTINATION state (re-fetched by the +// `email verify` command) and reports, per planned op, whether the +// destination matches the plan. It reuses the plan's own comparison +// machinery (canonEmailAddr, defaultsEquivalent) so verify can never +// disagree with the plan about what "equal" means. Mirror of dnsverify.go. + +// Verify op statuses (dnsverify vocabulary; `pending` = the destination +// still matches the plan-time state, legitimate before an apply, a +// failure after one; it gates either way). +const ( + EmailVerifyApplied = "applied" + EmailVerifyUnchanged = "unchanged" + EmailVerifyPending = "pending" + EmailVerifyDrift = "drift" + EmailVerifyManualReview = "manual_review" // manual op: reported for the human, never gates + EmailVerifyNotChecked = "not_checked" // 2B-2/2B-3 sections: verify does not re-list them yet + EmailVerifyUnavailable = "unavailable" // the fresh re-list failed, cannot verify => not verified +) + +// EmailVerifyOpResult is the verdict for one planned op. +type EmailVerifyOpResult struct { + Section string `json:"section"` + Action string `json:"action"` + Domain string `json:"domain"` + Key string `json:"key"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Expected string `json:"expected,omitempty"` + Observed string `json:"observed,omitempty"` +} + +type EmailVerifySummary struct { + Applied int `json:"applied"` + Unchanged int `json:"unchanged"` + Pending int `json:"pending"` + Drift int `json:"drift"` + ManualReview int `json:"manual_review"` + NotChecked int `json:"not_checked"` + Unavailable int `json:"unavailable"` + Untracked int `json:"untracked"` + ManualSections int `json:"manual_sections"` +} + +type EmailVerifyReport struct { + Mode string `json:"mode"` + FormatVersion int `json:"format_version"` + GeneratedAt string `json:"generated_at,omitempty"` + PlanFile string `json:"plan_file,omitempty"` + PlanSHA256 string `json:"plan_sha256,omitempty"` + Ops []EmailVerifyOpResult `json:"ops"` + // Untracked lists live 2B-1-section items that appear in neither the + // plan's ops nor its informational set: they postdate the plan. + // Informational, never gating (additive posture). Scope note: verify + // re-lists only the domains the plan knows about, items on domains + // the plan never saw are out of scope (dns verify has the same + // plan-scoped coverage). + Untracked []EmailPlanInfo `json:"untracked,omitempty"` + // ManualSections is the plan's list, passed through: the plan + // computed no ops for them, so their state is unknown, they gate. + ManualSections []EmailManualSection `json:"manual_sections,omitempty"` + Summary EmailVerifySummary `json:"summary"` + // Clean is the gate predicate: no pending, no drift, no unavailable + // op, no manual section. Manual OPS and untracked items never gate + // (the 6A/6C precedent: gating on manual would deadlock every real + // migration). + Clean bool `json:"clean"` +} + +// VerifyEmailPlan compares the live destination state against the plan. +func VerifyEmailPlan(plan EmailApplyPlan, live EmailLiveState) EmailVerifyReport { + rep := EmailVerifyReport{ + Mode: "email-verify", + FormatVersion: 1, + Ops: []EmailVerifyOpResult{}, + } + + for _, op := range plan.Ops { + res := verifyEmailOp(op, live, plan.DestinationUser) + rep.Ops = append(rep.Ops, res) + switch res.Status { + case EmailVerifyApplied: + rep.Summary.Applied++ + case EmailVerifyUnchanged: + rep.Summary.Unchanged++ + case EmailVerifyPending: + rep.Summary.Pending++ + case EmailVerifyDrift: + rep.Summary.Drift++ + case EmailVerifyManualReview: + rep.Summary.ManualReview++ + case EmailVerifyNotChecked: + rep.Summary.NotChecked++ + case EmailVerifyUnavailable: + rep.Summary.Unavailable++ + } + } + + rep.Untracked = emailUntracked(plan, live) + rep.Summary.Untracked = len(rep.Untracked) + + rep.ManualSections = append(rep.ManualSections, plan.ManualSections...) + rep.Summary.ManualSections = len(rep.ManualSections) + + rep.Clean = rep.Summary.Pending == 0 && + rep.Summary.Drift == 0 && + rep.Summary.Unavailable == 0 && + rep.Summary.ManualSections == 0 + return rep +} + +// verifyEmailOp applies the status table to one planned op. Every +// unexpected shape degrades to drift with a reason, fail-safe: a +// malformed or hand-edited plan can never verify clean. +func verifyEmailOp(op EmailPlanOp, live EmailLiveState, destUser string) EmailVerifyOpResult { + res := EmailVerifyOpResult{Section: op.Section, Action: op.Action, Domain: op.Domain, Key: op.Key} + + if op.Action == EmailActionManual { + res.Status, res.Reason = EmailVerifyManualReview, op.Reason + return res + } + switch { + case op.Section == EmailSectionAutoresponders: + if msg, failed := live.AutoresponderListErrors[op.Domain]; failed { + res.Status, res.Reason = EmailVerifyUnavailable, "fresh autoresponder re-list failed: "+msg + return res + } + if op.Autoresponder == nil { + res.Status = EmailVerifyDrift + res.Reason = fmt.Sprintf("autoresponder %s op carries no content payload, malformed or hand-edited plan", op.Action) + return res + } + res.Expected = op.Autoresponder.Subject + e, present := live.autoresponderFor(op.Domain, op.Key) + if present { + res.Observed = e.Subject + } + equivalent := present && autoresponderContentEquivalent(op.Autoresponder, e) + switch op.Action { + case EmailActionSkip: + if equivalent { + res.Status = EmailVerifyUnchanged + } else { + res.Status = EmailVerifyDrift + res.Reason = "the plan-time destination autoresponder is no longer live with the agreed content" + } + case EmailActionCreate: + switch { + case equivalent: + res.Status = EmailVerifyApplied + case !present: + res.Status = EmailVerifyPending + res.Reason = "autoresponder still missing on the destination (plan-time state)" + case !e.BodyCollected: + res.Status = EmailVerifyUnavailable + res.Reason = "an autoresponder exists but its body could not be read, cannot verify" + default: + res.Status = EmailVerifyDrift + res.Reason = "destination autoresponder matches neither the desired content nor the plan-time (absent) state" + } + default: + res.Status = EmailVerifyDrift + res.Reason = fmt.Sprintf("unexpected autoresponder action %q, malformed or hand-edited plan", op.Action) + } + return res + + case op.Section == EmailSectionForwarders: + if msg, failed := live.ForwarderListErrors[op.Domain]; failed { + res.Status, res.Reason = EmailVerifyUnavailable, "fresh forwarder re-list failed: "+msg + return res + } + pair := op.Forward + if op.Action == EmailActionSkip { + // A skip means the pair existed on both sides at plan time; + // the comparable target is the source value. + pair = op.SourceValue + } + res.Expected = pair + observed := live.destForwardTargets(op.Domain, op.Key) + res.Observed = strings.Join(observed, ", ") + present := live.forwardPairPresent(op.Domain, op.Key, pair) + switch op.Action { + case EmailActionSkip: + if present { + res.Status = EmailVerifyUnchanged + } else { + res.Status = EmailVerifyDrift + res.Reason = "the plan-time destination pair is no longer live" + } + case EmailActionCreate: + planTime := make([]string, 0, len(op.PlanTimeDestForwards)) + for _, t := range op.PlanTimeDestForwards { + planTime = append(planTime, canonEmailAddr(t)) + } + sort.Strings(planTime) + switch { + case present: + res.Status = EmailVerifyApplied + case stringSlicesEqual(planTime, observed): + res.Status = EmailVerifyPending + res.Reason = "pair still missing on the destination (plan-time state)" + default: + res.Status = EmailVerifyDrift + res.Reason = "destination forwarders match neither the desired nor the plan-time state" + } + default: + res.Status = EmailVerifyDrift + res.Reason = fmt.Sprintf("unexpected forwarder action %q, malformed or hand-edited plan", op.Action) + } + return res + + case op.Section == EmailSectionFilters: + account, filtername := splitFilterKey(op.Key) + if msg, failed := live.FilterListErrors[account]; failed { + res.Status, res.Reason = EmailVerifyUnavailable, "fresh filter re-list failed: "+msg + return res + } + if op.Filter == nil && op.Action != EmailActionSkip { + res.Status = EmailVerifyDrift + res.Reason = fmt.Sprintf("filter %s op carries no content payload, malformed or hand-edited plan", op.Action) + return res + } + e, present := live.filterFor(account, filtername) + equivalent := present && op.Filter != nil && filterContentEquivalent(op.Filter, e) + res.Expected = filtername + if present { + res.Observed = e.FilterName + } + switch op.Action { + case EmailActionSkip: + if op.Filter != nil && equivalent { + res.Status = EmailVerifyUnchanged + } else if present { + res.Status = EmailVerifyDrift + res.Reason = "the plan-time destination filter is no longer live with the agreed content" + } else { + res.Status = EmailVerifyDrift + res.Reason = "the plan-time destination filter no longer exists" + } + case EmailActionCreate: + switch { + case equivalent: + res.Status = EmailVerifyApplied + case !present: + res.Status = EmailVerifyPending + res.Reason = "filter still missing on the destination (plan-time state)" + case !e.RulesCollected: + res.Status = EmailVerifyUnavailable + res.Reason = "a filter exists but its rules could not be read, cannot verify" + default: + res.Status = EmailVerifyDrift + res.Reason = "destination filter matches neither the desired content nor the plan-time (absent) state" + } + default: + res.Status = EmailVerifyDrift + res.Reason = fmt.Sprintf("unexpected filter action %q, malformed or hand-edited plan", op.Action) + } + return res + + case op.Section == EmailSectionRouting: + if !live.RoutingListed { + res.Status, res.Reason = EmailVerifyUnavailable, "fresh routing re-list failed: "+live.RoutingError + return res + } + cur, ok := live.routingFor(op.Domain) + res.Observed = cur + if !ok { + res.Status, res.Reason = EmailVerifyDrift, "domain no longer appears in the routing list" + return res + } + switch op.Action { + case EmailActionSkip: + res.Expected = op.DestinationValue + if cur == op.DestinationValue { + res.Status = EmailVerifyUnchanged + } else { + res.Status = EmailVerifyDrift + res.Reason = "routing no longer matches the plan-time destination state" + } + case EmailActionSet: + res.Expected = op.Value + switch { + case cur == op.Value: + res.Status = EmailVerifyApplied + case cur == op.DestinationValue: + res.Status = EmailVerifyPending + res.Reason = "routing still carries the plan-time destination value" + default: + res.Status = EmailVerifyDrift + res.Reason = "routing matches neither the desired nor the plan-time state" + } + default: + res.Status = EmailVerifyDrift + res.Reason = fmt.Sprintf("unexpected routing action %q, malformed or hand-edited plan", op.Action) + } + return res + + default: // default_address + if !live.DefaultsListed { + res.Status, res.Reason = EmailVerifyUnavailable, "fresh default-address re-list failed: "+live.DefaultsError + return res + } + cur, ok := live.defaultFor(op.Domain) + res.Observed = cur + if !ok { + res.Status, res.Reason = EmailVerifyDrift, "domain no longer appears in the destination default-address list" + return res + } + switch op.Action { + case EmailActionSkip: + res.Expected = op.DestinationValue + if defaultsEquivalent(op.DestinationValue, cur, destUser, destUser) { + res.Status = EmailVerifyUnchanged + } else { + res.Status = EmailVerifyDrift + res.Reason = "live default no longer matches the plan-time destination state" + } + case EmailActionSet: + res.Expected = op.Value + switch { + case defaultsEquivalent(op.Value, cur, destUser, destUser): + res.Status = EmailVerifyApplied + case defaultsEquivalent(op.DestinationValue, cur, destUser, destUser): + res.Status = EmailVerifyPending + res.Reason = "default still carries the plan-time destination value" + default: + res.Status = EmailVerifyDrift + res.Reason = "live default matches neither the desired nor the plan-time state" + } + default: + res.Status = EmailVerifyDrift + res.Reason = fmt.Sprintf("unexpected default_address action %q, malformed or hand-edited plan", op.Action) + } + return res + } +} + +// emailUntracked lists live 2B-1-section items covered by neither the +// plan's ops nor its informational set. +func emailUntracked(plan EmailApplyPlan, live EmailLiveState) []EmailPlanInfo { + knownPairs := map[forwarderPair]bool{} + knownDefaults := map[string]bool{} + knownAutoresponders := map[string]bool{} + knownFilters := map[string]bool{} + knownRouting := map[string]bool{} + for _, op := range plan.Ops { + switch op.Section { + case EmailSectionForwarders: + target := op.Forward + if target == "" { + target = op.SourceValue + } + knownPairs[forwarderPair{Addr: canonEmailAddr(op.Key), Target: canonEmailAddr(target)}] = true + case EmailSectionDefaultAddress: + knownDefaults[op.Domain] = true + case EmailSectionAutoresponders: + knownAutoresponders[canonEmailAddr(op.Key)] = true + case EmailSectionFilters: + knownFilters[op.Key] = true + case EmailSectionRouting: + knownRouting[op.Domain] = true + } + } + for _, info := range plan.Informational { + switch info.Section { + case EmailSectionForwarders: + knownPairs[forwarderPair{Addr: canonEmailAddr(info.Key), Target: canonEmailAddr(info.Value)}] = true + case EmailSectionDefaultAddress: + knownDefaults[info.Domain] = true + case EmailSectionAutoresponders: + knownAutoresponders[canonEmailAddr(info.Key)] = true + case EmailSectionFilters: + knownFilters[info.Key] = true + case EmailSectionRouting: + knownRouting[info.Domain] = true + } + } + + var out []EmailPlanInfo + domains := make([]string, 0, len(live.ForwardersByDomain)) + for d := range live.ForwardersByDomain { + domains = append(domains, d) + } + sort.Strings(domains) + for _, d := range domains { + for _, f := range live.ForwardersByDomain[d] { + pair := forwarderPair{Addr: canonEmailAddr(f.Source), Target: canonEmailAddr(f.Destination)} + if knownPairs[pair] { + continue + } + out = append(out, EmailPlanInfo{ + Section: EmailSectionForwarders, Domain: d, + Key: canonEmailAddr(f.Source), Value: strings.TrimSpace(f.Destination), + }) + } + } + if live.DefaultsListed { + for _, e := range live.Defaults { + domain := strings.ToLower(strings.TrimSpace(e.Domain)) + if knownDefaults[domain] { + continue + } + out = append(out, EmailPlanInfo{ + Section: EmailSectionDefaultAddress, Domain: domain, + Key: domain, Value: e.DefaultAddress, + }) + } + } + arDomains := make([]string, 0, len(live.AutorespondersByDomain)) + for d := range live.AutorespondersByDomain { + arDomains = append(arDomains, d) + } + sort.Strings(arDomains) + for _, d := range arDomains { + for _, a := range live.AutorespondersByDomain[d] { + addr := canonEmailAddr(a.Email) + if knownAutoresponders[addr] { + continue + } + out = append(out, EmailPlanInfo{ + Section: EmailSectionAutoresponders, Domain: d, + Key: addr, Value: a.Subject, + }) + } + } + filterAccounts := make([]string, 0, len(live.FiltersByAccount)) + for a := range live.FiltersByAccount { + filterAccounts = append(filterAccounts, a) + } + sort.Strings(filterAccounts) + for _, acc := range filterAccounts { + for _, f := range live.FiltersByAccount[acc] { + key := filterKey(f) + if knownFilters[key] { + continue + } + out = append(out, EmailPlanInfo{ + Section: EmailSectionFilters, + Key: key, + Value: fmt.Sprintf("%d rule(s), %d action(s)", f.RuleCount, f.ActionCount), + }) + } + } + if live.RoutingListed { + for _, r := range live.RoutingEntries { + domain := strings.ToLower(strings.TrimSpace(r.Domain)) + if knownRouting[domain] { + continue + } + out = append(out, EmailPlanInfo{ + Section: EmailSectionRouting, + Domain: domain, + Key: domain, + Value: r.Routing, + }) + } + } + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + if a.Section != b.Section { + return emailPlanSectionOrder[a.Section] < emailPlanSectionOrder[b.Section] + } + if a.Key != b.Key { + return a.Key < b.Key + } + return a.Value < b.Value + }) + return out +} diff --git a/internal/accountinventory/emailverify_test.go b/internal/accountinventory/emailverify_test.go new file mode 100644 index 00000000..e622619e --- /dev/null +++ b/internal/accountinventory/emailverify_test.go @@ -0,0 +1,278 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "strings" + "testing" +) + +// evPlan builds a plan with one create, one set, one skip (forwarder), +// one manual and one routing skip, the full status surface. +func evPlan() EmailApplyPlan { + return EmailApplyPlan{ + Mode: "email-apply-plan", FormatVersion: 1, + DestinationUser: "acct", + Ops: []EmailPlanOp{ + {Section: EmailSectionForwarders, Action: EmailActionCreate, + Domain: "example.com", Key: "info@example.com", Email: "info", Forward: "someone@gmail.com"}, + {Section: EmailSectionForwarders, Action: EmailActionSkip, + Domain: "example.com", Key: "kept@example.com", SourceValue: "kept@target.com", + DestinationValue: "kept@target.com"}, + {Section: EmailSectionDefaultAddress, Action: EmailActionSet, + Domain: "example.com", Key: "example.com", Value: "someone@gmail.com", DestinationValue: "acct"}, + {Section: EmailSectionForwarders, Action: EmailActionManual, + Domain: "example.com", Key: "multi@example.com", Reason: "multi-target"}, + {Section: EmailSectionRouting, Action: EmailActionSkip, + Domain: "example.com", Key: "example.com", SourceValue: "local", DestinationValue: "local"}, + }, + } +} + +func evLive(fwds []NormForwarderEntry, defaults []DefaultAddressEntry) EmailLiveState { + return EmailLiveState{ + ForwardersByDomain: map[string][]NormForwarderEntry{"example.com": fwds}, + ForwarderListErrors: map[string]string{}, + Defaults: defaults, + DefaultsListed: true, + RoutingEntries: []EmailRoutingEntry{{Domain: "example.com", Routing: "local"}}, + RoutingListed: true, + FiltersByAccount: map[string][]NormEmailFilterEntry{}, + FilterListErrors: map[string]string{}, + } +} + +func evStatus(t *testing.T, rep EmailVerifyReport, section, key string) EmailVerifyOpResult { + t.Helper() + for _, op := range rep.Ops { + if op.Section == section && op.Key == key { + return op + } + } + t.Fatalf("no verify result for %s/%s", section, key) + return EmailVerifyOpResult{} +} + +// Before any apply: create/set are pending, skip unchanged, manual is +// manual_review, routing not_checked, NOT clean (pending gates). +func TestVerifyEmailPlanPendingBeforeApply(t *testing.T) { + live := evLive( + []NormForwarderEntry{{Source: "kept@example.com", Destination: "kept@target.com", Domain: "example.com"}}, + []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: "acct"}}, + ) + rep := VerifyEmailPlan(evPlan(), live) + + if s := evStatus(t, rep, EmailSectionForwarders, "info@example.com"); s.Status != EmailVerifyPending { + t.Errorf("create = %q (%s), want pending", s.Status, s.Reason) + } + if s := evStatus(t, rep, EmailSectionForwarders, "kept@example.com"); s.Status != EmailVerifyUnchanged { + t.Errorf("skip = %q (%s), want unchanged", s.Status, s.Reason) + } + if s := evStatus(t, rep, EmailSectionDefaultAddress, "example.com"); s.Status != EmailVerifyPending { + t.Errorf("set = %q (%s), want pending", s.Status, s.Reason) + } + if s := evStatus(t, rep, EmailSectionForwarders, "multi@example.com"); s.Status != EmailVerifyManualReview { + t.Errorf("manual = %q, want manual_review", s.Status) + } + if s := evStatus(t, rep, EmailSectionRouting, "example.com"); s.Status != EmailVerifyUnchanged { + t.Errorf("routing skip = %q, want unchanged (routing is now verified)", s.Status) + } + if rep.Clean { + t.Error("pending ops must gate: clean = true") + } +} + +// After the apply: create/set applied, verdict CLEAN (manual never gates). +func TestVerifyEmailPlanAppliedIsClean(t *testing.T) { + live := evLive( + []NormForwarderEntry{ + {Source: "kept@example.com", Destination: "kept@target.com", Domain: "example.com"}, + {Source: "info@example.com", Destination: "someone@gmail.com", Domain: "example.com"}, + }, + []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: "someone@gmail.com"}}, + ) + rep := VerifyEmailPlan(evPlan(), live) + + if s := evStatus(t, rep, EmailSectionForwarders, "info@example.com"); s.Status != EmailVerifyApplied { + t.Errorf("create = %q, want applied", s.Status) + } + if s := evStatus(t, rep, EmailSectionDefaultAddress, "example.com"); s.Status != EmailVerifyApplied { + t.Errorf("set = %q, want applied", s.Status) + } + if !rep.Clean { + t.Errorf("clean = false: %+v", rep.Summary) + } +} + +// Drift: a third value on the destination. +func TestVerifyEmailPlanDrift(t *testing.T) { + live := evLive( + []NormForwarderEntry{ + {Source: "info@example.com", Destination: "third@party.com", Domain: "example.com"}, + }, + []DefaultAddressEntry{{Domain: "example.com", DefaultAddress: "third@party.com"}}, + ) + rep := VerifyEmailPlan(evPlan(), live) + + if s := evStatus(t, rep, EmailSectionForwarders, "info@example.com"); s.Status != EmailVerifyDrift { + t.Errorf("create = %q, want drift", s.Status) + } + if s := evStatus(t, rep, EmailSectionForwarders, "kept@example.com"); s.Status != EmailVerifyDrift { + t.Errorf("skip whose pair vanished = %q, want drift", s.Status) + } + if s := evStatus(t, rep, EmailSectionDefaultAddress, "example.com"); s.Status != EmailVerifyDrift { + t.Errorf("set = %q, want drift", s.Status) + } + if rep.Clean { + t.Error("drift must gate") + } + // The untracked third-party pair is reported, informational. + if len(rep.Untracked) != 1 || rep.Untracked[0].Value != "third@party.com" { + t.Errorf("untracked = %+v", rep.Untracked) + } +} + +// A failed re-list makes ops unavailable, cannot verify => not verified. +func TestVerifyEmailPlanUnavailable(t *testing.T) { + live := EmailLiveState{ + ForwardersByDomain: map[string][]NormForwarderEntry{}, + ForwarderListErrors: map[string]string{"example.com": "ssh timeout"}, + DefaultsListed: false, + DefaultsError: "boom", + } + rep := VerifyEmailPlan(evPlan(), live) + if rep.Summary.Unavailable != 4 { // create, skip (fwd), set, skip (routing) + t.Errorf("unavailable = %d, want 4: %+v", rep.Summary.Unavailable, rep.Ops) + } + if rep.Clean { + t.Error("unavailable must gate") + } +} + +// Manual sections from the plan pass through and gate. +func TestVerifyEmailPlanManualSectionsGate(t *testing.T) { + plan := EmailApplyPlan{ + Mode: "email-apply-plan", FormatVersion: 1, Ops: []EmailPlanOp{}, + ManualSections: []EmailManualSection{{Section: EmailSectionDefaultAddress, Reason: "unavailable on source"}}, + } + rep := VerifyEmailPlan(plan, EmailLiveState{DefaultsListed: true}) + if rep.Clean || rep.Summary.ManualSections != 1 { + t.Errorf("clean = %v, manual sections = %d", rep.Clean, rep.Summary.ManualSections) + } + if !strings.Contains(rep.ManualSections[0].Reason, "unavailable") { + t.Errorf("reason = %q", rep.ManualSections[0].Reason) + } +} + +// --- autoresponders (PR 2B-2) ------------------------------------------------- + +func evAutoresponderPlan() EmailApplyPlan { + content := &EmailAutoresponderContent{ + From: "Info Desk", Subject: "Out of office", + Body: "On vacation.\n", IsHTML: 0, Interval: 8, Charset: "utf-8", + } + return EmailApplyPlan{ + Mode: "email-apply-plan", FormatVersion: 1, + DestinationUser: "acct", + Ops: []EmailPlanOp{ + {Section: EmailSectionAutoresponders, Action: EmailActionCreate, + Domain: "example.com", Key: "info@example.com", Email: "info", Autoresponder: content}, + {Section: EmailSectionAutoresponders, Action: EmailActionSkip, + Domain: "example.com", Key: "kept@example.com", SourceValue: "Kept", + DestinationValue: "Kept", Autoresponder: &EmailAutoresponderContent{ + From: "K", Subject: "Kept", Body: "Kept body.\n", Interval: 4, Charset: "utf-8"}}, + {Section: EmailSectionAutoresponders, Action: EmailActionManual, + Domain: "example.com", Key: "m@example.com", Reason: "content differs"}, + }, + } +} + +func evAutoresponderLive(ars []NormAutoresponderEntry) EmailLiveState { + live := evLive(nil, nil) + live.AutorespondersByDomain = map[string][]NormAutoresponderEntry{"example.com": ars} + live.AutoresponderListErrors = map[string]string{} + return live +} + +func TestVerifyEmailPlanAutoresponders(t *testing.T) { + applied := NormAutoresponderEntry{ + Email: "info@example.com", Domain: "example.com", + Subject: "Out of office", From: "Info Desk", Body: "On vacation.\n", + Interval: 8, Charset: "utf-8", BodyCollected: true, + } + kept := NormAutoresponderEntry{ + Email: "kept@example.com", Domain: "example.com", + Subject: "Kept", From: "K", Body: "Kept body.\n", + Interval: 4, Charset: "utf-8", BodyCollected: true, + } + + t.Run("before apply: create pending, skip unchanged, manual review, not clean", func(t *testing.T) { + rep := VerifyEmailPlan(evAutoresponderPlan(), evAutoresponderLive([]NormAutoresponderEntry{kept})) + if s := evStatus(t, rep, EmailSectionAutoresponders, "info@example.com"); s.Status != EmailVerifyPending { + t.Errorf("create = %q (%s), want pending", s.Status, s.Reason) + } + if s := evStatus(t, rep, EmailSectionAutoresponders, "kept@example.com"); s.Status != EmailVerifyUnchanged { + t.Errorf("skip = %q (%s), want unchanged", s.Status, s.Reason) + } + if s := evStatus(t, rep, EmailSectionAutoresponders, "m@example.com"); s.Status != EmailVerifyManualReview { + t.Errorf("manual = %q, want manual_review", s.Status) + } + if rep.Clean { + t.Error("pending create must gate") + } + }) + + t.Run("after apply: create applied, clean", func(t *testing.T) { + rep := VerifyEmailPlan(evAutoresponderPlan(), evAutoresponderLive([]NormAutoresponderEntry{applied, kept})) + if s := evStatus(t, rep, EmailSectionAutoresponders, "info@example.com"); s.Status != EmailVerifyApplied { + t.Errorf("create = %q (%s), want applied", s.Status, s.Reason) + } + if !rep.Clean { + t.Errorf("expected clean, summary %+v", rep.Summary) + } + }) + + t.Run("content diverged: drift", func(t *testing.T) { + bad := applied + bad.Body = "Qualcosa di diverso.\n" + rep := VerifyEmailPlan(evAutoresponderPlan(), evAutoresponderLive([]NormAutoresponderEntry{bad, kept})) + if s := evStatus(t, rep, EmailSectionAutoresponders, "info@example.com"); s.Status != EmailVerifyDrift { + t.Errorf("create = %q, want drift", s.Status) + } + gone := VerifyEmailPlan(evAutoresponderPlan(), evAutoresponderLive([]NormAutoresponderEntry{applied})) + if s := evStatus(t, gone, EmailSectionAutoresponders, "kept@example.com"); s.Status != EmailVerifyDrift { + t.Errorf("skip with vanished dest = %q, want drift", s.Status) + } + }) + + t.Run("re-list failed: unavailable, gates", func(t *testing.T) { + live := evAutoresponderLive(nil) + live.AutoresponderListErrors["example.com"] = "ssh timeout" + rep := VerifyEmailPlan(evAutoresponderPlan(), live) + if s := evStatus(t, rep, EmailSectionAutoresponders, "info@example.com"); s.Status != EmailVerifyUnavailable { + t.Errorf("create = %q, want unavailable", s.Status) + } + if rep.Clean { + t.Error("unavailable must gate") + } + }) + + t.Run("live autoresponder unknown to the plan: untracked, informational", func(t *testing.T) { + extra := NormAutoresponderEntry{Email: "new@example.com", Domain: "example.com", + Subject: "New", Body: "n\n", BodyCollected: true} + rep := VerifyEmailPlan(evAutoresponderPlan(), evAutoresponderLive([]NormAutoresponderEntry{applied, kept, extra})) + found := false + for _, u := range rep.Untracked { + if u.Section == EmailSectionAutoresponders && u.Key == "new@example.com" { + found = true + } + } + if !found { + t.Errorf("untracked = %+v, want new@example.com listed", rep.Untracked) + } + if !rep.Clean { + t.Error("untracked must never gate") + } + }) +} diff --git a/internal/accountinventory/emailverify_write.go b/internal/accountinventory/emailverify_write.go new file mode 100644 index 00000000..66753718 --- /dev/null +++ b/internal/accountinventory/emailverify_write.go @@ -0,0 +1,88 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// WriteEmailVerifyJSON writes the machine-readable verify report. +func WriteEmailVerifyJSON(path string, r EmailVerifyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + b, err := json.MarshalIndent(r, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal email verify report: %w", err) + } + b = append(b, '\n') + return os.WriteFile(path, b, 0o600) +} + +// WriteEmailVerifyMarkdown writes the human-readable verify report. +// Gate-relevant statuses (drift, pending, unavailable) come first. +func WriteEmailVerifyMarkdown(path string, r EmailVerifyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + fmt.Fprintf(&sb, "# Email Verify Report\n\n") + fmt.Fprintf(&sb, "- **Plan**: %s (sha256 %s)\n", r.PlanFile, r.PlanSHA256) + fmt.Fprintf(&sb, "- **Generated**: %s\n", r.GeneratedAt) + verdict := "CLEAN" + if !r.Clean { + verdict = "NOT CLEAN" + } + fmt.Fprintf(&sb, "- **Verdict**: %s\n\n", verdict) + fmt.Fprintf(&sb, "**Summary**: %d applied, %d unchanged, %d pending, %d drift, %d manual review, %d not checked, %d unavailable, %d untracked, %d manual section(s)\n\n", + r.Summary.Applied, r.Summary.Unchanged, r.Summary.Pending, r.Summary.Drift, + r.Summary.ManualReview, r.Summary.NotChecked, r.Summary.Unavailable, + r.Summary.Untracked, r.Summary.ManualSections) + + if len(r.ManualSections) > 0 { + sb.WriteString("## Sections the plan could not compute (gate)\n\n") + sb.WriteString("| Section | Reason |\n|---------|--------|\n") + for _, ms := range r.ManualSections { + fmt.Fprintf(&sb, "| %s | %s |\n", mdCell(ms.Section, 30), mdCell(ms.Reason, 100)) + } + sb.WriteString("\n") + } + + order := []string{EmailVerifyDrift, EmailVerifyUnavailable, EmailVerifyPending, + EmailVerifyApplied, EmailVerifyUnchanged, EmailVerifyManualReview, EmailVerifyNotChecked} + for _, status := range order { + var rows []EmailVerifyOpResult + for _, op := range r.Ops { + if op.Status == status { + rows = append(rows, op) + } + } + if len(rows) == 0 { + continue + } + fmt.Fprintf(&sb, "## %s (%d)\n\n", strings.ToUpper(strings.ReplaceAll(status, "_", " ")), len(rows)) + sb.WriteString("| Section | Key | Expected | Observed | Reason |\n|---------|-----|----------|----------|--------|\n") + for _, op := range rows { + fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s |\n", + mdCell(op.Section, 20), mdCell(op.Key, 50), mdCell(op.Expected, 50), + mdCell(op.Observed, 50), mdCell(op.Reason, 90)) + } + sb.WriteString("\n") + } + + if len(r.Untracked) > 0 { + fmt.Fprintf(&sb, "## Untracked destination items (%d, informational)\n\n", len(r.Untracked)) + sb.WriteString("| Section | Key | Value |\n|---------|-----|-------|\n") + for _, u := range r.Untracked { + fmt.Fprintf(&sb, "| %s | %s | %s |\n", mdCell(u.Section, 20), mdCell(u.Key, 50), mdCell(u.Value, 70)) + } + sb.WriteString("\n") + } + + return os.WriteFile(path, []byte(sb.String()), 0o600) +} diff --git a/internal/accountinventory/policy.go b/internal/accountinventory/policy.go new file mode 100644 index 00000000..35bd8537 --- /dev/null +++ b/internal/accountinventory/policy.go @@ -0,0 +1,657 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +// Policy engine v0: a deterministic rule table over an InventoryDiff. It +// classifies every difference as blocker / review / warning / info and +// rolls them up into an overall migration-readiness status. It NEVER +// decides what to do about a difference, never connects anywhere, and +// contains no heuristics - same diff in, same report out. + +import ( + "fmt" + "sort" + "strings" +) + +// Severity levels, weakest to strongest. +const ( + SeverityInfo = "info" + SeverityWarning = "warning" + SeverityReview = "review" + SeverityBlocker = "blocker" +) + +// Overall / per-finding statuses. +const ( + StatusReady = "ready" + StatusReviewRequired = "review_required" + StatusBlocked = "blocked" +) + +type PolicyFinding struct { + ID string `json:"id"` + Section string `json:"section"` + Severity string `json:"severity"` + Status string `json:"status"` + Title string `json:"title"` + Detail string `json:"detail"` + Recommendation string `json:"recommendation"` + SourceRef string `json:"source_ref,omitempty"` + DestinationRef string `json:"destination_ref,omitempty"` +} + +type PolicySummary struct { + Blockers int `json:"blockers"` + Reviews int `json:"reviews"` + Warnings int `json:"warnings"` + Info int `json:"info"` +} + +type PolicyReport struct { + Mode string `json:"mode"` + GeneratedAt string `json:"generated_at"` + InputDiff string `json:"input_diff"` + // InputDiffSHA256 hashes the raw bytes of the consumed diff file (set + // by the CLI); the checklist verifies the provenance chain against it + // (PR 7B). omitempty keeps older artifacts parseable. + InputDiffSHA256 string `json:"input_diff_sha256,omitempty"` + OverallStatus string `json:"overall_status"` + Summary PolicySummary `json:"summary"` + Findings []PolicyFinding `json:"findings"` + Warnings []string `json:"warnings"` +} + +func severityStatus(severity string) string { + switch severity { + case SeverityBlocker: + return StatusBlocked + case SeverityReview: + return StatusReviewRequired + default: + return StatusReady + } +} + +// severityRank orders findings most-severe first. +func severityRank(severity string) int { + switch severity { + case SeverityBlocker: + return 0 + case SeverityReview: + return 1 + case SeverityWarning: + return 2 + default: + return 3 + } +} + +// EvaluatePolicy applies the v0 rule table to a diff. Pure and +// deterministic: GeneratedAt/InputDiff are the caller's concern. +func EvaluatePolicy(d InventoryDiff) PolicyReport { + r := PolicyReport{ + Mode: "inventory-policy", + Findings: []PolicyFinding{}, + Warnings: []string{}, + } + emit := func(f PolicyFinding) { + f.Status = severityStatus(f.Severity) + r.Findings = append(r.Findings, f) + } + + for _, name := range diffSectionNames { + sec, ok := d.Sections[name] + if !ok { + // A diff produced by an older tool version (or otherwise + // incomplete) simply lacks the key: silence here would let a + // real difference in that area read as ok downstream. Same + // fail-safe class as POL-SECTION-UNAVAILABLE. + emit(PolicyFinding{ + ID: "POL-SECTION-MISSING", Section: name, Severity: SeverityReview, + Title: name + " missing from the diff", + Detail: "the diff artifact has no \"" + name + "\" section - produced by an older tool version or incomplete", + Recommendation: "Regenerate the diff with the current binary, then re-run the policy.", + }) + continue + } + switch name { + case "domains": + evalDomains(sec, emit) + case "mailboxes": + evalSimple(sec, emit, "mailboxes", "POL-MAILBOX", + "Mailbox", SeverityBlocker, "Recreate the mailbox on the destination before cutover.") + case "databases": + evalDatabases(sec, emit) + case "forwarders": + evalSimple(sec, emit, "forwarders", "POL-FORWARDER", + "Forwarder", SeverityReview, "Recreate the forwarder on the destination or confirm it is obsolete.") + case "autoresponders": + evalSimple(sec, emit, "autoresponders", "POL-AUTORESPONDER", + "Autoresponder", SeverityReview, "Recreate the autoresponder on the destination or confirm it is obsolete.") + case "ftp": + evalSimple(sec, emit, "ftp", "POL-FTP", + "FTP account", SeverityReview, "Recreate the FTP account on the destination or confirm it is obsolete.") + case "ssl": + evalSSL(sec, d, emit) + case "php": + evalPHP(sec, emit) + case "dns": + evalDNS(sec, emit) + case "cron": + evalCron(sec, emit) + case "email_routing": + evalSimple(sec, emit, "email_routing", "POL-MAILROUTE", + "Mail routing", SeverityReview, "Confirm the destination mail routing (local/remote) matches where this domain's mail is really hosted; a wrong value silently breaks delivery.") + case "default_address": + evalSimple(sec, emit, "default_address", "POL-DEFAULTADDR", + "Default address", SeverityReview, "Recreate the default (catch-all) address on the destination or confirm the difference is intended; a lost catch-all silently drops mail.") + case "email_filters": + evalSimple(sec, emit, "email_filters", "POL-EMAILFILTER", + "Email filter", SeverityReview, "Recreate the filter on the destination or confirm it is obsolete.") + case "redirects": + evalRedirects(sec, emit) + } + evalSectionWarnings(name, sec, emit) + } + + for i := range r.Findings { + switch r.Findings[i].Severity { + case SeverityBlocker: + r.Summary.Blockers++ + case SeverityReview: + r.Summary.Reviews++ + case SeverityWarning: + r.Summary.Warnings++ + default: + r.Summary.Info++ + } + } + switch { + case r.Summary.Blockers > 0: + r.OverallStatus = StatusBlocked + case r.Summary.Reviews > 0: + r.OverallStatus = StatusReviewRequired + default: + r.OverallStatus = StatusReady + } + + sort.Slice(r.Findings, func(i, j int) bool { + a, b := r.Findings[i], r.Findings[j] + if sa, sb := severityRank(a.Severity), severityRank(b.Severity); sa != sb { + return sa < sb + } + if a.Section != b.Section { + return a.Section < b.Section + } + if a.ID != b.ID { + return a.ID < b.ID + } + if a.Detail != b.Detail { + return a.Detail < b.Detail + } + // Refs complete the ordering: several sections emit findings with + // an empty Detail (e.g. two removed mailboxes) that differ only + // by the item they reference. + if a.SourceRef != b.SourceRef { + return a.SourceRef < b.SourceRef + } + return a.DestinationRef < b.DestinationRef + }) + return r +} + +// evalSectionWarnings turns the diff's structured signals into findings: +// a skipped comparison (structured Skipped field, never matched by prose) +// means incomplete data, which can never be "ready"; free-text diff +// warnings surface as non-gating warnings. +func evalSectionWarnings(section string, sec SectionDiff, emit func(PolicyFinding)) { + for _, s := range sec.Skipped { + emit(PolicyFinding{ + ID: "POL-SECTION-UNAVAILABLE", Section: section, Severity: SeverityReview, + Title: fmt.Sprintf("%s comparison incomplete", section), + Detail: s, + Recommendation: "Re-run the inventory so both sides expose this section, then diff again.", + }) + } + for _, w := range sec.Warnings { + emit(PolicyFinding{ + ID: "POL-DIFF-WARNING", Section: section, Severity: SeverityWarning, + Title: fmt.Sprintf("%s diff warning", section), + Detail: w, + Recommendation: "Inspect the diff warning; it may hide data-quality issues.", + }) + } +} + +// evalSimple covers sections whose policy is uniform: removals get +// removedSeverity, changes get review, additions are informational. +func evalSimple(sec SectionDiff, emit func(PolicyFinding), section, idPrefix, noun, removedSeverity, removedRecommendation string) { + for _, e := range sec.Removed { + emit(PolicyFinding{ + ID: idPrefix + "-REMOVED", Section: section, Severity: removedSeverity, + Title: noun + " missing on destination", + Detail: e.Detail, + Recommendation: removedRecommendation, + SourceRef: e.Key, + }) + } + for _, c := range sec.Changed { + emit(PolicyFinding{ + ID: idPrefix + "-CHANGED", Section: section, Severity: SeverityReview, + Title: fmt.Sprintf("%s %s differs", noun, c.Field), + Detail: fmt.Sprintf("%s: %s -> %s", c.Field, c.Source, c.Destination), + Recommendation: "Confirm the destination value is intended.", + SourceRef: c.Key, DestinationRef: c.Key, + }) + } + for _, e := range sec.Added { + emit(PolicyFinding{ + ID: idPrefix + "-ADDED", Section: section, Severity: SeverityInfo, + Title: noun + " present only on destination", + Detail: e.Detail, + Recommendation: "No action needed unless unexpected.", + DestinationRef: e.Key, + }) + } +} + +func evalDomains(sec SectionDiff, emit func(PolicyFinding)) { + for _, e := range sec.Removed { + if e.Detail == "main" { + emit(PolicyFinding{ + ID: "POL-DOMAIN-MAIN-REMOVED", Section: "domains", Severity: SeverityBlocker, + Title: "Main domain missing on destination", + Detail: "type: main", + Recommendation: "The destination account must serve the main domain before any cutover.", + SourceRef: e.Key, + }) + continue + } + emit(PolicyFinding{ + ID: "POL-DOMAIN-REMOVED", Section: "domains", Severity: SeverityReview, + Title: "Domain missing on destination", + Detail: "type: " + e.Detail, + Recommendation: "Create the domain on the destination or confirm it is being dropped.", + SourceRef: e.Key, + }) + } + for _, c := range sec.Changed { + if c.Field == "document_root" { + // Docroot layouts legitimately differ between hosts. + emit(PolicyFinding{ + ID: "POL-DOMAIN-DOCROOT-CHANGED", Section: "domains", Severity: SeverityInfo, + Title: "Document root differs", + Detail: fmt.Sprintf("%s -> %s", c.Source, c.Destination), + Recommendation: "Expected across hosts; verify only if the layout matters to the site.", + SourceRef: c.Key, DestinationRef: c.Key, + }) + continue + } + emit(PolicyFinding{ + ID: "POL-DOMAIN-TYPE-CHANGED", Section: "domains", Severity: SeverityReview, + Title: "Domain type differs", + Detail: fmt.Sprintf("%s: %s -> %s", c.Field, c.Source, c.Destination), + Recommendation: "A domain served with a different type may behave differently; verify.", + SourceRef: c.Key, DestinationRef: c.Key, + }) + } + for _, e := range sec.Added { + emit(PolicyFinding{ + ID: "POL-DOMAIN-ADDED", Section: "domains", Severity: SeverityInfo, + Title: "Domain present only on destination", + Detail: "type: " + e.Detail, + Recommendation: "No action needed unless unexpected.", + DestinationRef: e.Key, + }) + } +} + +func evalDatabases(sec SectionDiff, emit func(PolicyFinding)) { + for _, e := range sec.Removed { + emit(PolicyFinding{ + ID: "POL-DB-REMOVED", Section: "databases", Severity: SeverityBlocker, + Title: "Database missing on destination", + Detail: e.Detail, + Recommendation: "Migrate the database before cutover.", + SourceRef: e.Key, + }) + } + for _, c := range sec.Changed { + emit(PolicyFinding{ + ID: "POL-DB-USERS-CHANGED", Section: "databases", Severity: SeverityReview, + Title: "Database grants differ", + Detail: fmt.Sprintf("%s: %s -> %s", c.Field, c.Source, c.Destination), + Recommendation: "Verify the application's DB user still has the grants it needs.", + SourceRef: c.Key, DestinationRef: c.Key, + }) + } + for _, e := range sec.Added { + emit(PolicyFinding{ + ID: "POL-DB-ADDED", Section: "databases", Severity: SeverityInfo, + Title: "Database present only on destination", + Detail: e.Detail, + Recommendation: "No action needed unless unexpected.", + DestinationRef: e.Key, + }) + } +} + +func evalSSL(sec SectionDiff, d InventoryDiff, emit func(PolicyFinding)) { + // A certificate that disappeared together with ALL of its domains is + // coherent; one whose domain still exists is a blocker. + removedDomains := map[string]bool{} + if domains, ok := d.Sections["domains"]; ok { + for _, e := range domains.Removed { + removedDomains[e.Key] = true + } + } + for _, e := range sec.Removed { + if e.Key == "" { + // A certificate entry without a domain list cannot be + // cross-checked: fail closed with an explicit note instead of + // an empty, unactionable Item column. + emit(PolicyFinding{ + ID: "POL-SSL-REMOVED", Section: "ssl", Severity: SeverityBlocker, + Title: "Certificate missing for a domain still present", + Detail: "certificate entry carries no domain list - verify manually", + Recommendation: "Issue or install a certificate on the destination before cutover.", + SourceRef: "(no domain list)", + }) + continue + } + allGone := true + for _, dom := range strings.Split(e.Key, ",") { + if !removedDomains[strings.TrimSpace(dom)] { + allGone = false + break + } + } + if allGone { + emit(PolicyFinding{ + ID: "POL-SSL-REMOVED-WITH-DOMAIN", Section: "ssl", Severity: SeverityInfo, + Title: "Certificate removed together with its domains", + Detail: e.Detail, + Recommendation: "Coherent with the domain removal; no separate action.", + SourceRef: e.Key, + }) + continue + } + emit(PolicyFinding{ + ID: "POL-SSL-REMOVED", Section: "ssl", Severity: SeverityBlocker, + Title: "Certificate missing for a domain still present", + Detail: e.Detail, + Recommendation: "Issue or install a certificate on the destination before cutover.", + SourceRef: e.Key, + }) + } + for _, c := range sec.Changed { + emit(PolicyFinding{ + ID: "POL-SSL-CHANGED", Section: "ssl", Severity: SeverityReview, + Title: fmt.Sprintf("Certificate %s differs", c.Field), + Detail: fmt.Sprintf("%s: %s -> %s", c.Field, c.Source, c.Destination), + Recommendation: "Verify the destination certificate covers the cutover window.", + SourceRef: c.Key, DestinationRef: c.Key, + }) + } + for _, e := range sec.Added { + emit(PolicyFinding{ + ID: "POL-SSL-ADDED", Section: "ssl", Severity: SeverityInfo, + Title: "Certificate present only on destination", + Detail: e.Detail, + Recommendation: "No action needed unless unexpected.", + DestinationRef: e.Key, + }) + } +} + +func evalPHP(sec SectionDiff, emit func(PolicyFinding)) { + for _, e := range sec.Removed { + emit(PolicyFinding{ + ID: "POL-PHP-REMOVED", Section: "php", Severity: SeverityReview, + Title: "PHP vhost configuration missing on destination", + Detail: e.Detail, + Recommendation: "Confirm the destination serves this vhost with an intended PHP version.", + SourceRef: e.Key, + }) + } + for _, c := range sec.Changed { + emit(PolicyFinding{ + ID: "POL-PHP-CHANGED", Section: "php", Severity: SeverityReview, + Title: "PHP version differs", + Detail: fmt.Sprintf("%s -> %s", c.Source, c.Destination), + Recommendation: "Test the site against the destination PHP version before cutover.", + SourceRef: c.Key, DestinationRef: c.Key, + }) + } + for _, e := range sec.Added { + emit(PolicyFinding{ + ID: "POL-PHP-ADDED", Section: "php", Severity: SeverityInfo, + Title: "PHP vhost present only on destination", + Detail: e.Detail, + Recommendation: "No action needed unless unexpected.", + DestinationRef: e.Key, + }) + } +} + +// dnsKeyType extracts the record type from a DNS diff key +// ("zone "); zone-level keys ("zone ") return "". +// A 3-token key (record whose owner name decoded empty) is still a +// RECORD-level key: classifying it as a whole zone would produce an +// alarming, wrong "zone missing" blocker for one malformed record. +func dnsKeyType(key string) string { + fields := strings.Fields(key) + if len(fields) >= 3 && fields[0] == "zone" { + return fields[2] + } + return "" +} + +func isMailRoutingType(t string) bool { return t == "MX" || t == "NS" } + +func evalDNS(sec SectionDiff, emit func(PolicyFinding)) { + for _, e := range sec.Removed { + typ := dnsKeyType(e.Key) + switch { + case typ == "": // whole zone gone - every record, MX included + emit(PolicyFinding{ + ID: "POL-DNS-ZONE-REMOVED", Section: "dns", Severity: SeverityBlocker, + Title: "DNS zone missing on destination", + Detail: e.Detail, + Recommendation: "The destination must serve this zone before any cutover.", + SourceRef: e.Key, + }) + case isMailRoutingType(typ): + emit(PolicyFinding{ + ID: "POL-DNS-" + typ + "-REMOVED", Section: "dns", Severity: SeverityBlocker, + Title: typ + " record missing on destination", + Detail: e.Detail, + Recommendation: "Mail/delegation routing would break; recreate the record before cutover.", + SourceRef: e.Key, + }) + default: + emit(PolicyFinding{ + ID: "POL-DNS-RECORD-REMOVED", Section: "dns", Severity: SeverityReview, + Title: typ + " record missing on destination", + Detail: e.Detail, + Recommendation: "Recreate the record or confirm it is obsolete.", + SourceRef: e.Key, + }) + } + } + for _, c := range sec.Changed { + typ := dnsKeyType(c.Key) + switch { + case isMailRoutingType(typ): + emit(PolicyFinding{ + ID: "POL-DNS-" + typ + "-CHANGED", Section: "dns", Severity: SeverityBlocker, + Title: typ + " record differs", + Detail: fmt.Sprintf("%s -> %s", c.Source, c.Destination), + Recommendation: "Review mail/delegation routing before cutover.", + SourceRef: c.Key, DestinationRef: c.Key, + }) + case typ == "SOA": + // SOA serial/timers differ on virtually every regenerated + // zone: review-severity here would only cause finding fatigue. + emit(PolicyFinding{ + ID: "POL-DNS-SOA-CHANGED", Section: "dns", Severity: SeverityInfo, + Title: "SOA record differs", + Detail: fmt.Sprintf("%s -> %s", c.Source, c.Destination), + Recommendation: "Expected when a zone is regenerated on a new host; no action needed.", + SourceRef: c.Key, DestinationRef: c.Key, + }) + default: + emit(PolicyFinding{ + ID: "POL-DNS-RECORD-CHANGED", Section: "dns", Severity: SeverityReview, + Title: typ + " record differs", + Detail: fmt.Sprintf("%s -> %s", c.Source, c.Destination), + Recommendation: "Verify the destination value (SPF/DKIM/DMARC and app records especially).", + SourceRef: c.Key, DestinationRef: c.Key, + }) + } + } + for _, e := range sec.Added { + typ := dnsKeyType(e.Key) + switch { + case typ == "": + emit(PolicyFinding{ + ID: "POL-DNS-ZONE-ADDED", Section: "dns", Severity: SeverityInfo, + Title: "DNS zone present only on destination", + Detail: e.Detail, + Recommendation: "No action needed unless unexpected.", + DestinationRef: e.Key, + }) + case isMailRoutingType(typ): + emit(PolicyFinding{ + ID: "POL-DNS-MAIL-RECORD-ADDED", Section: "dns", Severity: SeverityReview, + Title: typ + " record present only on destination", + Detail: e.Detail, + Recommendation: "New mail/delegation routing appears on the destination; confirm it is intended.", + DestinationRef: e.Key, + }) + default: + emit(PolicyFinding{ + ID: "POL-DNS-RECORD-ADDED", Section: "dns", Severity: SeverityInfo, + Title: typ + " record present only on destination", + Detail: e.Detail, + Recommendation: "No action needed unless unexpected.", + DestinationRef: e.Key, + }) + } + } +} + +func evalCron(sec SectionDiff, emit func(PolicyFinding)) { + for _, e := range sec.Removed { + if strings.Contains(e.Detail, "enabled=false") { + emit(PolicyFinding{ + ID: "POL-CRON-DISABLED-REMOVED", Section: "cron", Severity: SeverityInfo, + Title: "Disabled cron job missing on destination", + Detail: e.Detail, + Recommendation: "The job was disabled; recreate only if you plan to re-enable it.", + SourceRef: e.Key, + }) + continue + } + emit(PolicyFinding{ + ID: "POL-CRON-ENABLED-REMOVED", Section: "cron", Severity: SeverityBlocker, + Title: "Active cron job missing on destination", + Detail: e.Detail, + Recommendation: "Recreate the job on the destination before cutover.", + SourceRef: e.Key, + }) + } + for _, c := range sec.Changed { + id := "POL-CRON-SCHEDULE-CHANGED" + title := "Cron schedule differs" + if c.Field == "enabled" { + id, title = "POL-CRON-ENABLED-CHANGED", "Cron enabled state differs" + } + emit(PolicyFinding{ + ID: id, Section: "cron", Severity: SeverityReview, + Title: title, + Detail: fmt.Sprintf("%s: %s -> %s", c.Field, c.Source, c.Destination), + Recommendation: "Confirm the destination scheduling is intended.", + SourceRef: c.Key, DestinationRef: c.Key, + }) + } + for _, e := range sec.Added { + emit(PolicyFinding{ + ID: "POL-CRON-ADDED", Section: "cron", Severity: SeverityInfo, + Title: "Cron job present only on destination", + Detail: e.Detail, + Recommendation: "No action needed unless unexpected.", + DestinationRef: e.Key, + }) + } +} + +// cmsRewriteDetailPrefix marks a redirect whose diff detail identifies +// it as a CMS-generated .htaccess RewriteRule (kind=rewrite, +// type=temporary, no status code - PR7E_PRE_CAPTURES.md fact 4). Those +// rules travel with the web files, so their absence on a not-yet-synced +// destination is expected, not operator work. +const cmsRewriteDetailPrefix = "rewrite/temporary/- " + +// isCMSRewriteDetail additionally requires a NON-URL destination: every +// CMS rewrite captured live rewrites to a relative path +// (`%{ENV:REWRITEBASE}img/...`), while operator-created redirects always +// target an absolute URL (the cPanel UI requires one). An operator +// "temporary" redirect that reports no status code therefore still +// classifies as genuine - the fail-safe direction. A hand-authored +// rewrite to a relative path stays CMS-class: that .htaccess content +// travels with the web files either way. +func isCMSRewriteDetail(detail string) bool { + if !strings.HasPrefix(detail, cmsRewriteDetailPrefix) { + return false + } + dest := strings.TrimPrefix(detail, cmsRewriteDetailPrefix+"-> ") + return !strings.HasPrefix(dest, "http://") && + !strings.HasPrefix(dest, "https://") && + !strings.HasPrefix(dest, "//") +} + +// evalRedirects: CMS-generated rewrites are informational (they live in +// .htaccess and migrate with the web files); genuine redirects follow +// the uniform removed->review / changed->review / added->info policy. +func evalRedirects(sec SectionDiff, emit func(PolicyFinding)) { + for _, e := range sec.Removed { + if isCMSRewriteDetail(e.Detail) { + emit(PolicyFinding{ + ID: "POL-REDIRECT-CMS-REMOVED", Section: "redirects", Severity: SeverityInfo, + Title: "CMS rewrite not on destination yet", + Detail: e.Detail, + Recommendation: "No action: .htaccess RewriteRules travel with the web files migration.", + SourceRef: e.Key, + }) + continue + } + emit(PolicyFinding{ + ID: "POL-REDIRECT-REMOVED", Section: "redirects", Severity: SeverityReview, + Title: "Redirect missing on destination", + Detail: e.Detail, + Recommendation: "Recreate the redirect on the destination (or confirm the web files migration carries its .htaccess rule).", + SourceRef: e.Key, + }) + } + for _, c := range sec.Changed { + emit(PolicyFinding{ + ID: "POL-REDIRECT-CHANGED", Section: "redirects", Severity: SeverityReview, + Title: fmt.Sprintf("Redirect %s differs", c.Field), + Detail: fmt.Sprintf("%s: %s -> %s", c.Field, c.Source, c.Destination), + Recommendation: "Confirm the destination redirect is intended.", + SourceRef: c.Key, DestinationRef: c.Key, + }) + } + for _, e := range sec.Added { + emit(PolicyFinding{ + ID: "POL-REDIRECT-ADDED", Section: "redirects", Severity: SeverityInfo, + Title: "Redirect present only on destination", + Detail: e.Detail, + Recommendation: "No action needed unless unexpected.", + DestinationRef: e.Key, + }) + } +} diff --git a/internal/accountinventory/policy_test.go b/internal/accountinventory/policy_test.go new file mode 100644 index 00000000..3e6355cf --- /dev/null +++ b/internal/accountinventory/policy_test.go @@ -0,0 +1,447 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "strings" + "testing" +) + +// emptyDiff returns a diff with all 10 sections present and clean. +func emptyDiff() InventoryDiff { + return DiffInventories(baseInventory(), baseInventory()) +} + +// diffWith returns an empty diff with one section replaced. +func diffWith(section string, sec SectionDiff) InventoryDiff { + d := emptyDiff() + d.Sections[section] = sec + // Keep the summary coherent with the mutation. + d.Summary.Added += len(sec.Added) + d.Summary.Removed += len(sec.Removed) + d.Summary.Changed += len(sec.Changed) + d.Summary.Warnings += len(sec.Warnings) + return d +} + +func added(entries ...DiffEntry) SectionDiff { + s := newSectionDiff() + s.Added = entries + return s +} + +func removed(entries ...DiffEntry) SectionDiff { + s := newSectionDiff() + s.Removed = entries + return s +} + +func changed(entries ...DiffFieldChange) SectionDiff { + s := newSectionDiff() + s.Changed = entries + return s +} + +func findingByID(t *testing.T, r PolicyReport, id string) PolicyFinding { + t.Helper() + for _, f := range r.Findings { + if f.ID == id { + return f + } + } + t.Fatalf("finding %s not found in %+v", id, r.Findings) + return PolicyFinding{} +} + +func assertNoFindingID(t *testing.T, r PolicyReport, id string) { + t.Helper() + for _, f := range r.Findings { + if f.ID == id { + t.Fatalf("unexpected finding %s: %+v", id, f) + } + } +} + +// --------------------------------------------------------------------------- +// Overall status +// --------------------------------------------------------------------------- + +func TestPolicyEmptyDiffReady(t *testing.T) { + r := EvaluatePolicy(emptyDiff()) + if r.Mode != "inventory-policy" { + t.Errorf("mode = %q", r.Mode) + } + if r.OverallStatus != "ready" { + t.Errorf("status = %q, want ready", r.OverallStatus) + } + if len(r.Findings) != 0 { + t.Errorf("findings = %+v, want none", r.Findings) + } +} + +func TestPolicyBlockerWinsOverall(t *testing.T) { + d := diffWith("mailboxes", removed(DiffEntry{Key: "info@main.example"})) + d.Sections["php"] = changed(DiffFieldChange{Key: "main.example", Field: "version", Source: "ea-php74", Destination: "ea-php81"}) + r := EvaluatePolicy(d) + if r.OverallStatus != "blocked" { + t.Errorf("status = %q, want blocked", r.OverallStatus) + } + if r.Summary.Blockers == 0 || r.Summary.Reviews == 0 { + t.Errorf("summary = %+v", r.Summary) + } +} + +func TestPolicyOnlyReviewRequired(t *testing.T) { + d := diffWith("php", changed(DiffFieldChange{Key: "main.example", Field: "version", Source: "a", Destination: "b"})) + r := EvaluatePolicy(d) + if r.OverallStatus != "review_required" { + t.Errorf("status = %q, want review_required", r.OverallStatus) + } +} + +func TestPolicyOnlyInfoIsReady(t *testing.T) { + d := diffWith("mailboxes", added(DiffEntry{Key: "new@main.example"})) + r := EvaluatePolicy(d) + if r.OverallStatus != "ready" { + t.Errorf("status = %q, want ready (info only)", r.OverallStatus) + } + if r.Summary.Info == 0 { + t.Errorf("summary = %+v, want info counted", r.Summary) + } +} + +// --------------------------------------------------------------------------- +// Mail / database / domains +// --------------------------------------------------------------------------- + +func TestPolicyMailboxRemovedBlocker(t *testing.T) { + r := EvaluatePolicy(diffWith("mailboxes", removed(DiffEntry{Key: "info@main.example"}))) + f := findingByID(t, r, "POL-MAILBOX-REMOVED") + if f.Severity != "blocker" || f.Status != "blocked" { + t.Errorf("finding = %+v", f) + } +} + +func TestPolicyDatabaseRemovedBlocker(t *testing.T) { + r := EvaluatePolicy(diffWith("databases", removed(DiffEntry{Key: "u_wp"}))) + if findingByID(t, r, "POL-DB-REMOVED").Severity != "blocker" { + t.Error("database removed must be blocker") + } +} + +func TestPolicyAddedMailboxAndDBAreInfo(t *testing.T) { + d := diffWith("mailboxes", added(DiffEntry{Key: "new@x"})) + d.Sections["databases"] = added(DiffEntry{Key: "u_new"}) + r := EvaluatePolicy(d) + if findingByID(t, r, "POL-MAILBOX-ADDED").Severity != "info" { + t.Error("mailbox added must be info") + } + if findingByID(t, r, "POL-DB-ADDED").Severity != "info" { + t.Error("database added must be info") + } + if r.OverallStatus != "ready" { + t.Errorf("status = %q", r.OverallStatus) + } +} + +func TestPolicyMainDomainRemovedBlocker(t *testing.T) { + r := EvaluatePolicy(diffWith("domains", removed(DiffEntry{Key: "main.example", Detail: "main"}))) + if findingByID(t, r, "POL-DOMAIN-MAIN-REMOVED").Severity != "blocker" { + t.Error("main domain removed must be blocker") + } +} + +func TestPolicyAddonDomainRemovedReview(t *testing.T) { + r := EvaluatePolicy(diffWith("domains", removed(DiffEntry{Key: "addon.example", Detail: "addon"}))) + if findingByID(t, r, "POL-DOMAIN-REMOVED").Severity != "review" { + t.Error("non-main domain removed must be review") + } +} + +func TestPolicyDocrootChangeIsInfo(t *testing.T) { + // Docroots legitimately differ across hosts: informational only. + r := EvaluatePolicy(diffWith("domains", changed(DiffFieldChange{ + Key: "main.example", Field: "document_root", Source: "/home/a/x", Destination: "/home/b/x", + }))) + if findingByID(t, r, "POL-DOMAIN-DOCROOT-CHANGED").Severity != "info" { + t.Error("docroot change must be info") + } + if r.OverallStatus != "ready" { + t.Errorf("status = %q", r.OverallStatus) + } +} + +// --------------------------------------------------------------------------- +// DNS +// --------------------------------------------------------------------------- + +func TestPolicyDNSMXChangedBlocker(t *testing.T) { + r := EvaluatePolicy(diffWith("dns", changed(DiffFieldChange{ + Key: "zone main.example MX main.example.", Field: "records", + Source: "prio=10 a. ttl=1", Destination: "prio=10 b. ttl=1", + }))) + if findingByID(t, r, "POL-DNS-MX-CHANGED").Severity != "blocker" { + t.Error("MX changed must be blocker") + } +} + +func TestPolicyDNSMXRemovedBlocker(t *testing.T) { + r := EvaluatePolicy(diffWith("dns", removed(DiffEntry{Key: "zone main.example MX main.example.", Detail: "prio=10 m. ttl=1"}))) + if findingByID(t, r, "POL-DNS-MX-REMOVED").Severity != "blocker" { + t.Error("MX removed must be blocker") + } +} + +func TestPolicyDNSNSChangedBlocker(t *testing.T) { + r := EvaluatePolicy(diffWith("dns", changed(DiffFieldChange{ + Key: "zone main.example NS main.example.", Field: "records", Source: "a", Destination: "b", + }))) + if findingByID(t, r, "POL-DNS-NS-CHANGED").Severity != "blocker" { + t.Error("NS changed must be blocker") + } +} + +func TestPolicyDNSTXTChangedReview(t *testing.T) { + r := EvaluatePolicy(diffWith("dns", changed(DiffFieldChange{ + Key: "zone main.example TXT dkim._domainkey.main.example.", Field: "records", + Source: "v=DKIM1... ttl=1", Destination: "v=DKIM1;other ttl=1", + }))) + f := findingByID(t, r, "POL-DNS-RECORD-CHANGED") + if f.Severity != "review" { + t.Errorf("TXT changed = %q, want review", f.Severity) + } +} + +func TestPolicyDNSAChangedReview(t *testing.T) { + r := EvaluatePolicy(diffWith("dns", changed(DiffFieldChange{ + Key: "zone main.example A main.example.", Field: "records", Source: "198.51.100.1 ttl=1", Destination: "198.51.100.2 ttl=1", + }))) + if findingByID(t, r, "POL-DNS-RECORD-CHANGED").Severity != "review" { + t.Error("A changed must be review") + } +} + +func TestPolicyDNSAddedByType(t *testing.T) { + d := diffWith("dns", added( + DiffEntry{Key: "zone main.example A extra.main.example.", Detail: "198.51.100.9 ttl=1"}, + DiffEntry{Key: "zone main.example MX main.example.", Detail: "prio=20 backup. ttl=1"}, + )) + r := EvaluatePolicy(d) + if findingByID(t, r, "POL-DNS-RECORD-ADDED").Severity != "info" { + t.Error("A added must be info") + } + if findingByID(t, r, "POL-DNS-MAIL-RECORD-ADDED").Severity != "review" { + t.Error("MX added must be review") + } +} + +func TestPolicyDNSZoneRemovedBlocker(t *testing.T) { + r := EvaluatePolicy(diffWith("dns", removed(DiffEntry{Key: "zone gone.example", Detail: "12 record(s)"}))) + if findingByID(t, r, "POL-DNS-ZONE-REMOVED").Severity != "blocker" { + t.Error("whole zone removed must be blocker") + } +} + +// --------------------------------------------------------------------------- +// Cron +// --------------------------------------------------------------------------- + +func TestPolicyCronEnabledRemovedBlocker(t *testing.T) { + r := EvaluatePolicy(diffWith("cron", removed(DiffEntry{ + Key: "/bin/backup --password=[REDACTED]", Detail: "0 3 * * * enabled=true", + }))) + if findingByID(t, r, "POL-CRON-ENABLED-REMOVED").Severity != "blocker" { + t.Error("enabled cron removed must be blocker") + } +} + +func TestPolicyCronDisabledRemovedInfo(t *testing.T) { + r := EvaluatePolicy(diffWith("cron", removed(DiffEntry{ + Key: "/bin/old.sh", Detail: "30 2 * * 0 enabled=false", + }))) + f := findingByID(t, r, "POL-CRON-DISABLED-REMOVED") + if f.Severity != "info" { + t.Errorf("disabled cron removed = %q, want info", f.Severity) + } + assertNoFindingID(t, r, "POL-CRON-ENABLED-REMOVED") +} + +func TestPolicyCronScheduleChangedReview(t *testing.T) { + r := EvaluatePolicy(diffWith("cron", changed(DiffFieldChange{ + Key: "/bin/backup --password=[REDACTED]", Field: "schedule", Source: "0 3 * * *", Destination: "0 5 * * *", + }))) + if findingByID(t, r, "POL-CRON-SCHEDULE-CHANGED").Severity != "review" { + t.Error("cron schedule changed must be review") + } +} + +// --------------------------------------------------------------------------- +// SSL / PHP / sections +// --------------------------------------------------------------------------- + +func TestPolicySSLRemovedBlocker(t *testing.T) { + r := EvaluatePolicy(diffWith("ssl", removed(DiffEntry{Key: "main.example,www.main.example", Detail: "R3"}))) + if findingByID(t, r, "POL-SSL-REMOVED").Severity != "blocker" { + t.Error("SSL removed for a present domain must be blocker") + } +} + +func TestPolicySSLRemovedWithDomainIsInfo(t *testing.T) { + // Certificate gone together with its (removed) domains: coherent. + d := diffWith("ssl", removed(DiffEntry{Key: "gone.example,www.gone.example", Detail: "R3"})) + d.Sections["domains"] = removed( + DiffEntry{Key: "gone.example", Detail: "addon"}, + DiffEntry{Key: "www.gone.example", Detail: "sub"}, + ) + r := EvaluatePolicy(d) + f := findingByID(t, r, "POL-SSL-REMOVED-WITH-DOMAIN") + if f.Severity != "info" { + t.Errorf("ssl removed with its domains = %q, want info", f.Severity) + } + assertNoFindingID(t, r, "POL-SSL-REMOVED") +} + +func TestPolicySSLChangedReview(t *testing.T) { + r := EvaluatePolicy(diffWith("ssl", changed(DiffFieldChange{ + Key: "main.example", Field: "valid_until", Source: "1", Destination: "2", + }))) + if findingByID(t, r, "POL-SSL-CHANGED").Severity != "review" { + t.Error("SSL changed must be review") + } +} + +func TestPolicyPHPChangedReview(t *testing.T) { + r := EvaluatePolicy(diffWith("php", changed(DiffFieldChange{ + Key: "main.example", Field: "version", Source: "ea-php74", Destination: "ea-php81", + }))) + if findingByID(t, r, "POL-PHP-CHANGED").Severity != "review" { + t.Error("PHP version changed must be review") + } +} + +func TestPolicyFTPRemovedReview(t *testing.T) { + r := EvaluatePolicy(diffWith("ftp", removed(DiffEntry{Key: "up@main.example"}))) + if findingByID(t, r, "POL-FTP-REMOVED").Severity != "review" { + t.Error("FTP removed must be review") + } +} + +func TestPolicyForwarderRemovedReviewAddedInfo(t *testing.T) { + d := diffWith("forwarders", removed(DiffEntry{Key: "main.example | a@b -> c@d"})) + d.Sections["autoresponders"] = added(DiffEntry{Key: "main.example | new@main.example"}) + r := EvaluatePolicy(d) + if findingByID(t, r, "POL-FORWARDER-REMOVED").Severity != "review" { + t.Error("forwarder removed must be review") + } + if findingByID(t, r, "POL-AUTORESPONDER-ADDED").Severity != "info" { + t.Error("autoresponder added must be info") + } +} + +func TestPolicySectionUnavailableReview(t *testing.T) { + // Gating happens on the STRUCTURED Skipped field, not on warning + // prose: rewording a message can never turn incomplete data "ready". + sec := newSectionDiff() + sec.Skipped = append(sec.Skipped, "cron unavailable on destination") + r := EvaluatePolicy(diffWith("cron", sec)) + f := findingByID(t, r, "POL-SECTION-UNAVAILABLE") + if f.Severity != "review" { + t.Errorf("unavailable section = %q, want review", f.Severity) + } + if r.OverallStatus != "review_required" { + t.Errorf("status = %q - incomplete data can never be ready", r.OverallStatus) + } +} + +func TestPolicyGenericWarningDoesNotGate(t *testing.T) { + sec := newSectionDiff() + sec.Warnings = append(sec.Warnings, `duplicate key "x" in source - last occurrence wins`) + r := EvaluatePolicy(diffWith("domains", sec)) + if findingByID(t, r, "POL-DIFF-WARNING").Severity != "warning" { + t.Error("generic diff warning must be severity warning") + } + if r.OverallStatus != "ready" { + t.Errorf("status = %q - plain warnings must not gate", r.OverallStatus) + } +} + +func TestPolicyDNSSOAChangedIsInfo(t *testing.T) { + // SOA serial/timers differ on virtually every regenerated zone: + // review-severity here would only train operators to skim findings. + r := EvaluatePolicy(diffWith("dns", changed(DiffFieldChange{ + Key: "zone main.example SOA main.example.", Field: "records", + Source: "ns1. admin. 2026010101 ttl=1", Destination: "ns2. admin. 2026070101 ttl=1", + }))) + f := findingByID(t, r, "POL-DNS-SOA-CHANGED") + if f.Severity != "info" { + t.Errorf("SOA changed = %q, want info", f.Severity) + } + if r.OverallStatus != "ready" { + t.Errorf("status = %q", r.OverallStatus) + } +} + +func TestPolicyDNSKeyEmptyNameNotZone(t *testing.T) { + // A malformed record-level key with an empty owner name must NOT be + // classified as a whole missing zone (blocker with a wrong headline). + r := EvaluatePolicy(diffWith("dns", removed(DiffEntry{Key: "zone main.example TXT", Detail: "x ttl=1"}))) + assertNoFindingID(t, r, "POL-DNS-ZONE-REMOVED") + if findingByID(t, r, "POL-DNS-RECORD-REMOVED").Severity != "review" { + t.Error("record with empty name must classify as record-level") + } +} + +func TestPolicySortTiebreakerOnRefs(t *testing.T) { + // Two removed mailboxes: same severity/section/id and empty Detail - + // the refs must order them deterministically. + d := diffWith("mailboxes", removed(DiffEntry{Key: "b@x"}, DiffEntry{Key: "a@x"})) + r := EvaluatePolicy(d) + if len(r.Findings) != 2 { + t.Fatalf("findings = %d", len(r.Findings)) + } + if r.Findings[0].SourceRef != "a@x" || r.Findings[1].SourceRef != "b@x" { + t.Errorf("findings not ordered by ref: %+v", r.Findings) + } +} + +// --------------------------------------------------------------------------- +// Determinism and hygiene +// --------------------------------------------------------------------------- + +func TestPolicyDeterministicOrder(t *testing.T) { + d := diffWith("mailboxes", removed(DiffEntry{Key: "b@x"}, DiffEntry{Key: "a@x"})) + d.Sections["php"] = changed(DiffFieldChange{Key: "x", Field: "version", Source: "1", Destination: "2"}) + r1 := EvaluatePolicy(d) + r2 := EvaluatePolicy(d) + if len(r1.Findings) != len(r2.Findings) { + t.Fatal("finding count differs between runs") + } + for i := range r1.Findings { + if r1.Findings[i] != r2.Findings[i] { + t.Errorf("non-deterministic finding order at %d", i) + } + } + // Blockers first. + if r1.Findings[0].Severity != "blocker" { + t.Errorf("first finding = %+v, want blocker first", r1.Findings[0]) + } +} + +func TestPolicyNoNilSlices(t *testing.T) { + r := EvaluatePolicy(emptyDiff()) + if r.Findings == nil || r.Warnings == nil { + t.Errorf("nil slices in report: %+v", r) + } +} + +func TestPolicyRedactedContentPreserved(t *testing.T) { + // The policy must carry the redacted command through, never expand it. + r := EvaluatePolicy(diffWith("cron", removed(DiffEntry{ + Key: "/bin/backup --password=[REDACTED] \\| gzip", Detail: "0 3 * * * enabled=true", + }))) + f := findingByID(t, r, "POL-CRON-ENABLED-REMOVED") + if !strings.Contains(f.SourceRef, "[REDACTED]") && !strings.Contains(f.Detail, "[REDACTED]") { + t.Errorf("redacted command lost: %+v", f) + } +} diff --git a/internal/accountinventory/policy_write.go b/internal/accountinventory/policy_write.go new file mode 100644 index 00000000..f9f99cb8 --- /dev/null +++ b/internal/accountinventory/policy_write.go @@ -0,0 +1,77 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// WritePolicyJSON writes the machine-readable policy report. +func WritePolicyJSON(path string, r PolicyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + b, err := json.MarshalIndent(r, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal policy: %w", err) + } + b = append(b, '\n') + return os.WriteFile(path, b, 0o600) +} + +// WritePolicyMarkdown writes the human report. Cells go through mdCell so +// redacted commands and long record values stay table-safe previews. +func WritePolicyMarkdown(path string, r PolicyReport) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + + fmt.Fprintf(&sb, "# Migration Policy Report\n\n") + fmt.Fprintf(&sb, "- **Input diff**: %s\n", r.InputDiff) + fmt.Fprintf(&sb, "- **Generated**: %s\n", r.GeneratedAt) + fmt.Fprintf(&sb, "- **Overall status**: **%s**\n\n", r.OverallStatus) + fmt.Fprintf(&sb, "**Summary**: %d blocker(s), %d review(s), %d warning(s), %d info\n\n", + r.Summary.Blockers, r.Summary.Reviews, r.Summary.Warnings, r.Summary.Info) + + for _, w := range r.Warnings { + fmt.Fprintf(&sb, "> **Warning**: %s\n\n", w) + } + + if len(r.Findings) == 0 { + sb.WriteString("No findings: the inventories match.\n") + return os.WriteFile(path, []byte(sb.String()), 0o600) + } + + for _, severity := range []string{SeverityBlocker, SeverityReview, SeverityWarning, SeverityInfo} { + var rows []PolicyFinding + for _, f := range r.Findings { + if f.Severity == severity { + rows = append(rows, f) + } + } + if len(rows) == 0 { + continue + } + fmt.Fprintf(&sb, "## %s (%d)\n\n", strings.ToUpper(severity), len(rows)) + sb.WriteString("| ID | Section | Item | Title | Detail | Recommendation |\n") + sb.WriteString("|----|---------|------|-------|--------|----------------|\n") + for _, f := range rows { + item := f.SourceRef + if item == "" { + item = f.DestinationRef + } + fmt.Fprintf(&sb, "| %s | %s | %s | %s | %s | %s |\n", + mdCell(f.ID, 40), mdCell(f.Section, 15), mdCell(item, 60), mdCell(f.Title, 50), + mdCell(f.Detail, 70), mdCell(f.Recommendation, 70)) + } + sb.WriteString("\n") + } + + return os.WriteFile(path, []byte(sb.String()), 0o600) +} diff --git a/internal/accountinventory/policy_write_test.go b/internal/accountinventory/policy_write_test.go new file mode 100644 index 00000000..0dd3812a --- /dev/null +++ b/internal/accountinventory/policy_write_test.go @@ -0,0 +1,133 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func samplePolicyReport(t *testing.T) PolicyReport { + t.Helper() + d := diffWith("cron", removed(DiffEntry{ + Key: "/bin/dump db \\| gzip --password=[REDACTED]", Detail: "0 3 * * * enabled=true", + })) + d.Sections["php"] = changed(DiffFieldChange{Key: "main.example", Field: "version", Source: "ea-php74", Destination: "ea-php81"}) + r := EvaluatePolicy(d) + r.InputDiff = "inventory_diff.json" + r.GeneratedAt = "2026-07-01T00:00:00Z" + return r +} + +func TestWritePolicyJSONNoNulls(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "policy_report.json") + if err := WritePolicyJSON(path, samplePolicyReport(t)); err != nil { + t.Fatalf("WritePolicyJSON: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + var walk func(v any, p string) + walk = func(v any, p string) { + switch val := v.(type) { + case nil: + t.Errorf("null at %s", p) + case map[string]any: + for k, c := range val { + walk(c, p+"."+k) + } + case []any: + for _, c := range val { + walk(c, p+"[]") + } + } + } + walk(m, "$") + if m["mode"] != "inventory-policy" { + t.Errorf("mode = %v", m["mode"]) + } + if m["overall_status"] != "blocked" { + t.Errorf("overall_status = %v", m["overall_status"]) + } +} + +func TestWritePolicyMarkdown(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "policy_report.md") + if err := WritePolicyMarkdown(path, samplePolicyReport(t)); err != nil { + t.Fatalf("WritePolicyMarkdown: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := string(b) + for _, want := range []string{ + "# Migration Policy Report", + "**blocked**", + "BLOCKER (1)", "REVIEW (1)", + "POL-CRON-ENABLED-REMOVED", "POL-PHP-CHANGED", + "[REDACTED]", + } { + if !strings.Contains(s, want) { + t.Errorf("markdown missing %q", want) + } + } + // Table-safety: the piped redacted command must not break rows. + if strings.Contains(s, " | gzip") { + t.Error("unescaped pipe leaked into a table row") + } +} + +func TestWritePolicyMarkdownNewlineInjectionSafe(t *testing.T) { + // A crafted TXT record containing newlines must not break out of its + // table cell (DNS values are attacker-influenced free text). + d := diffWith("dns", changed(DiffFieldChange{ + Key: "zone main.example TXT evil.main.example.", Field: "records", + Source: "v=ok ttl=1", + Destination: "v=evil ttl=1\n\n# Injected heading\n| fake | row |", + })) + r := EvaluatePolicy(d) + r.InputDiff, r.GeneratedAt = "d.json", "t" + dir := t.TempDir() + path := filepath.Join(dir, "policy.md") + if err := WritePolicyMarkdown(path, r); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(path) + // Inline occurrences inside a cell are harmless; breaking out means + // the injected text starts its OWN line. + if strings.Contains(string(b), "\n# Injected heading") { + t.Error("newline in a record value broke out of its markdown cell") + } + if strings.Contains(string(b), "\n| fake | row |") { + t.Error("injected table row escaped its cell") + } +} + +func TestWritePolicyMarkdownClean(t *testing.T) { + r := EvaluatePolicy(emptyDiff()) + r.InputDiff, r.GeneratedAt = "d.json", "t" + dir := t.TempDir() + path := filepath.Join(dir, "policy.md") + if err := WritePolicyMarkdown(path, r); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(path) + if !strings.Contains(string(b), "No findings") { + t.Errorf("clean report should say so:\n%s", b) + } + if !strings.Contains(string(b), "**ready**") { + t.Errorf("clean report must be ready:\n%s", b) + } +} diff --git a/internal/accountinventory/types.go b/internal/accountinventory/types.go new file mode 100644 index 00000000..a42996e9 --- /dev/null +++ b/internal/accountinventory/types.go @@ -0,0 +1,353 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// Package accountinventory collects a read-only inventory of a cPanel account: +// domains, docroots, mailboxes, databases, and DNS zones. +// It never writes to any server. +package accountinventory + +import ( + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" +) + +type AccountInfo struct { + User string `json:"user"` + Host string `json:"host"` + CollectedAt string `json:"collected_at"` + Side string `json:"side"` +} + +type DomainEntry struct { + Name string `json:"name"` + Type string `json:"type"` + DocumentRoot string `json:"document_root,omitempty"` +} + +type MailboxEntry struct { + Email string `json:"email"` + Domain string `json:"domain"` + User string `json:"user"` + DiskUsage int64 `json:"disk_usage,omitempty"` +} + +type DatabaseEntry struct { + Name string `json:"name"` + DiskUsage int64 `json:"disk_usage,omitempty"` + Users []string `json:"users"` +} + +type NormForwarderEntry struct { + Source string `json:"source"` + Destination string `json:"destination"` + Domain string `json:"domain"` +} + +// NormAutoresponderEntry is one autoresponder with its full content (PR 2B-2). +// Email is always the full local@domain address; Domain is the QUERIED +// domain (real list_auto_responders rows carry no domain field). The +// content fields (From, Body, IsHTML, Interval, Start, Stop, Charset) come +// from get_auto_responder and are only trustworthy when BodyCollected is +// true: a false value means the entry carries list-level facts only +// (pre-2B-2 artifact, or the per-address get failed - see Warnings) and no +// equality over the content can be proven. +type NormAutoresponderEntry struct { + Email string `json:"email"` + Domain string `json:"domain"` + Subject string `json:"subject"` + Interval int `json:"interval"` + From string `json:"from,omitempty"` + Body string `json:"body,omitempty"` + IsHTML int `json:"is_html,omitempty"` + Start int64 `json:"start,omitempty"` + Stop int64 `json:"stop,omitempty"` + Charset string `json:"charset,omitempty"` + // BodyCollected reports that get_auto_responder succeeded for this + // address (2B-2 body collector). It is the honesty marker the email + // plan gates on before proving autoresponder equality. + BodyCollected bool `json:"body_collected,omitempty"` +} + +type FTPEntry struct { + Login string `json:"login"` + Type string `json:"type"` + Dir string `json:"dir"` + DiskUsed int64 `json:"disk_used"` +} + +type SSLEntry struct { + Domains string `json:"domains"` + Issuer string `json:"issuer"` + ValidFrom int64 `json:"valid_from"` + ValidUntil int64 `json:"valid_until"` + IsSelfSigned bool `json:"is_self_signed"` + ValidationType string `json:"validation_type"` +} + +type PHPEntry struct { + Domain string `json:"domain"` + Version string `json:"version"` +} + +type ConfigSection struct { + Available bool `json:"available"` + Method string `json:"method"` + SourceFunction string `json:"source_function"` + Warnings []string `json:"warnings"` +} + +type FTPSection struct { + ConfigSection + Items []FTPEntry `json:"items"` +} + +type SSLSection struct { + ConfigSection + Items []SSLEntry `json:"items"` +} + +type PHPSection struct { + ConfigSection + Items []PHPEntry `json:"items"` +} + +type DNSZoneResult struct { + Available bool `json:"available"` + Zone string `json:"zone"` + Method string `json:"method"` + SourceFunction string `json:"source_function"` + Records []cpanel.DNSRecord `json:"records"` + Warnings []string `json:"warnings"` + Errors []string `json:"errors"` + RawIncluded bool `json:"raw_included"` +} + +type DNSSection struct { + ConfigSection + Zones []DNSZoneResult `json:"zones"` +} + +// CronJobEntry is one crontab job. The redacted fields are the display +// and diff contract. The clear/raw fields (CommandClear, RawLine) are +// in-memory-only and tagged json:"-": they are NEVER serialized into the +// read-only inventory (inventory_source.json), which must not leak secrets +// like secure=. The read-only collector leaves them empty; they are +// populated exclusively by the Batch-D apply path, which re-collects the +// crontab fresh. CommandCollected marks a real parsed job (redacted command +// present) so the plan classifies it (create/skip/manual); comparison rides +// CommandSHA256, not the raw text. Display (diff, policy, checklist, +// Markdown) uses the redacted fields exclusively. +type CronJobEntry struct { + Type string `json:"type"` + Minute string `json:"minute,omitempty"` + Hour string `json:"hour,omitempty"` + DayOfMonth string `json:"day_of_month,omitempty"` + Month string `json:"month,omitempty"` + DayOfWeek string `json:"day_of_week,omitempty"` + Macro string `json:"macro,omitempty"` + CommandRedacted string `json:"command_redacted"` + CommandClear string `json:"-"` // in-memory only (Batch-D apply writer); never serialized + CommandSHA256 string `json:"command_sha256"` + RawLineSHA256 string `json:"raw_line_sha256"` + RawLine string `json:"-"` // in-memory only (Batch-D apply writer); never serialized + Enabled bool `json:"enabled"` + LineNumber int `json:"line_number"` + Warnings []string `json:"warnings"` + // CommandCollected is true for a real job parsed from the crontab + // (redacted command present). The cron plan gates on this before + // classifying create/skip/manual. + CommandCollected bool `json:"command_collected,omitempty"` +} + +// CronEnvEntry is one crontab environment assignment. ValueClear is +// in-memory-only and tagged json:"-": never serialized into the read-only +// inventory (secret-leak surface, same as CommandClear/RawLine). The +// read-only collector leaves it empty; it is populated exclusively by the +// Batch-D apply path. ValueCollected marks a real parsed entry. +type CronEnvEntry struct { + Name string `json:"name"` + ValueRedacted string `json:"value_redacted"` + ValueClear string `json:"-"` // in-memory only (Batch-D apply writer); never serialized + LineNumber int `json:"line_number"` + ValueCollected bool `json:"value_collected,omitempty"` +} + +// CronSection deviates from ConfigSection on purpose: crontab is fetched by +// a shell command, not a cPanel API function, so it carries source_command +// instead of source_function. +type CronSection struct { + Available bool `json:"available"` + Method string `json:"method"` // "ssh_crontab_l" | "unavailable" + SourceCommand string `json:"source_command"` + Jobs []CronJobEntry `json:"jobs"` + Environment []CronEnvEntry `json:"environment"` + CommentsCount int `json:"comments_count"` + DisabledJobsCount int `json:"disabled_jobs_count"` + Warnings []string `json:"warnings"` + Errors []string `json:"errors"` +} + +// MXRecordEntry is one MX record row of an email-routing domain. +type MXRecordEntry struct { + Priority int64 `json:"priority"` + Exchange string `json:"exchange"` +} + +// EmailRoutingEntry is one domain's mail-routing mode (PR 7E). Only +// mail-routing domains appear here - subdomains have a default address +// but no routing entry of their own, so this section's domain universe +// is narrower than DefaultAddresses'. +type EmailRoutingEntry struct { + Domain string `json:"domain"` + Routing string `json:"routing"` // configured mxcheck: local | remote | auto | secondary + Detected string `json:"detected"` // what cPanel detects from the MX records + AlwaysAccept bool `json:"always_accept"` + MXRecords []MXRecordEntry `json:"mx_records"` +} + +// DefaultAddressEntry is one domain's catch-all configuration (PR 7E); +// the value is opaque (the cPanel default embeds literal quotes). +type DefaultAddressEntry struct { + Domain string `json:"domain"` + DefaultAddress string `json:"default_address"` +} + +// FilterRule is one rule of an email filter (2B-3-pre fact 1-3). The +// opt field is always null in all observed responses but is retained for +// completeness. match_type (AND/OR join between rules) is NOT returned +// by list_filters or get_filter - see 2B-3-pre fact 10. +type FilterRule struct { + Part string `json:"part"` + Match string `json:"match"` + Opt any `json:"opt"` + Val string `json:"val"` +} + +// FilterAction is one action of an email filter. Dest is null (nil +// pointer) for actions that have no destination (fail, finish). +type FilterAction struct { + Action string `json:"action"` + Dest *string `json:"dest"` +} + +// NormEmailFilterEntry is one email filter (PR 7E, extended in 2B-3). Since +// the user decision (2B-3 gate: option A), rules and actions are stored +// in clear in the inventory for round-trip fidelity. RulesCollected is +// the honesty marker: true means get_filter succeeded and the Rules/ +// Actions slices are trustworthy; false means the entry carries +// list-level facts only (pre-2B-3 artifact, or a per-filter get failed) +// and no equality over the content can be proven. RuleCount/ActionCount +// are kept for backward compatibility with pre-2B-3 artifacts. +// +// [WARN] match_type (AND/OR join between rules) is NOT round-trippable: +// the cPanel API does not return it (2B-3-pre fact 10). Single-rule +// filters are safe (match_type irrelevant); multi-rule filters must be +// classified MANUAL by the plan. +type NormEmailFilterEntry struct { + Account string `json:"account"` // "" = account-level (all mail) + FilterName string `json:"filter_name"` + Enabled bool `json:"enabled"` + RuleCount int `json:"rule_count"` + ActionCount int `json:"action_count"` + // Rules and Actions carry the full filter content (2B-3, option A). + // Populated when RulesCollected is true. + Rules []FilterRule `json:"rules,omitempty"` + Actions []FilterAction `json:"actions,omitempty"` + RulesCollected bool `json:"rules_collected,omitempty"` +} + +// RedirectEntry is one redirect/rewrite harvested from .htaccess by +// cPanel (PR 7E). Raw facts only - the CMS-noise classification +// (rewrite+temporary+no status code) belongs to the policy layer. +type RedirectEntry struct { + Domain string `json:"domain"` + Source string `json:"source"` + Destination string `json:"destination"` + Kind string `json:"kind"` + Type string `json:"type"` + StatusCode int64 `json:"status_code"` // 0 = none reported + Wildcard bool `json:"wildcard"` + MatchWWW bool `json:"match_www"` +} + +type EmailRoutingSection struct { + ConfigSection + Items []EmailRoutingEntry `json:"items"` +} + +type DefaultAddressSection struct { + ConfigSection + Items []DefaultAddressEntry `json:"items"` +} + +type EmailFilterSection struct { + ConfigSection + Items []NormEmailFilterEntry `json:"items"` +} + +type RedirectSection struct { + ConfigSection + Items []RedirectEntry `json:"items"` +} + +type NormalizedInventory struct { + Account AccountInfo `json:"account"` + Domains []DomainEntry `json:"domains"` + Mailboxes []MailboxEntry `json:"mailboxes"` + Databases []DatabaseEntry `json:"databases"` + Forwarders []NormForwarderEntry `json:"forwarders"` + Autoresponders []NormAutoresponderEntry `json:"autoresponders"` + FTP FTPSection `json:"ftp"` + SSL SSLSection `json:"ssl"` + PHP PHPSection `json:"php"` + DNS DNSSection `json:"dns"` + Cron CronSection `json:"cron"` + EmailRouting EmailRoutingSection `json:"email_routing"` + DefaultAddresses DefaultAddressSection `json:"default_address"` + EmailFilters EmailFilterSection `json:"email_filters"` + Redirects RedirectSection `json:"redirects"` + Warnings []string `json:"warnings"` +} + +// NewEmptyCronSection returns a CronSection with every slice initialized so +// JSON output never contains null arrays. +func NewEmptyCronSection() CronSection { + return CronSection{ + SourceCommand: "crontab -l", + Jobs: []CronJobEntry{}, + Environment: []CronEnvEntry{}, + Warnings: []string{}, + Errors: []string{}, + } +} + +func NewEmptyInventory(user, host, side string) NormalizedInventory { + return NormalizedInventory{ + Account: AccountInfo{ + User: user, + Host: host, + CollectedAt: time.Now().UTC().Format(time.RFC3339), + Side: side, + }, + Domains: []DomainEntry{}, + Mailboxes: []MailboxEntry{}, + Databases: []DatabaseEntry{}, + Forwarders: []NormForwarderEntry{}, + Autoresponders: []NormAutoresponderEntry{}, + FTP: FTPSection{ConfigSection: ConfigSection{Warnings: []string{}}, Items: []FTPEntry{}}, + SSL: SSLSection{ConfigSection: ConfigSection{Warnings: []string{}}, Items: []SSLEntry{}}, + PHP: PHPSection{ConfigSection: ConfigSection{Warnings: []string{}}, Items: []PHPEntry{}}, + DNS: DNSSection{ConfigSection: ConfigSection{Warnings: []string{}}, Zones: []DNSZoneResult{}}, + Cron: NewEmptyCronSection(), + EmailRouting: EmailRoutingSection{ + ConfigSection: ConfigSection{Warnings: []string{}}, Items: []EmailRoutingEntry{}}, + DefaultAddresses: DefaultAddressSection{ + ConfigSection: ConfigSection{Warnings: []string{}}, Items: []DefaultAddressEntry{}}, + EmailFilters: EmailFilterSection{ + ConfigSection: ConfigSection{Warnings: []string{}}, Items: []NormEmailFilterEntry{}}, + Redirects: RedirectSection{ + ConfigSection: ConfigSection{Warnings: []string{}}, Items: []RedirectEntry{}}, + Warnings: []string{}, + } +} diff --git a/internal/accountinventory/types_test.go b/internal/accountinventory/types_test.go new file mode 100644 index 00000000..4d64da7f --- /dev/null +++ b/internal/accountinventory/types_test.go @@ -0,0 +1,105 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestNormalizedInventoryJSON(t *testing.T) { + inv := NormalizedInventory{ + Account: AccountInfo{ + User: "srcuser", + Host: "192.0.2.1", + CollectedAt: "2026-07-01T15:30:00Z", + Side: "source", + }, + Domains: []DomainEntry{ + {Name: "main.example", Type: "main", DocumentRoot: "/home/srcuser/public_html"}, + {Name: "addon.example", Type: "addon", DocumentRoot: "/home/srcuser/addon.example"}, + }, + Mailboxes: []MailboxEntry{ + {Email: "info@main.example", Domain: "main.example", User: "info", DiskUsage: 1024}, + }, + Databases: []DatabaseEntry{ + {Name: "srcuser_wp1", DiskUsage: 50000, Users: []string{"srcuser_admin"}}, + }, + Warnings: []string{}, + } + + b, err := json.MarshalIndent(inv, "", " ") + if err != nil { + t.Fatalf("Marshal: %v", err) + } + s := string(b) + + for _, want := range []string{ + `"account"`, `"user"`, `"host"`, `"collected_at"`, `"side"`, + `"domains"`, `"name"`, `"type"`, `"document_root"`, + `"mailboxes"`, `"email"`, `"domain"`, `"disk_usage"`, + `"databases"`, `"users"`, + `"dns"`, `"zones"`, + `"cron"`, `"jobs"`, `"source_command"`, + `"email_routing"`, `"default_address"`, `"email_filters"`, `"redirects"`, + `"warnings"`, + } { + if !strings.Contains(s, want) { + t.Errorf("JSON missing %s", want) + } + } + for _, bad := range []string{"password", "token", "secret", "hash"} { + if strings.Contains(strings.ToLower(s), bad) { + t.Errorf("JSON contains sensitive keyword %q", bad) + } + } +} + +func TestNormalizedInventoryRoundTrip(t *testing.T) { + inv := NormalizedInventory{ + Account: AccountInfo{User: "u", Host: "h", CollectedAt: "t", Side: "source"}, + Domains: []DomainEntry{{Name: "d", Type: "main"}}, + Mailboxes: []MailboxEntry{{Email: "a@d", Domain: "d", User: "a"}}, + Databases: []DatabaseEntry{{Name: "db", Users: []string{"u"}}}, + Warnings: []string{"w1"}, + } + b, err := json.Marshal(inv) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var got NormalizedInventory + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.Account.User != "u" { + t.Errorf("Account.User = %q, want %q", got.Account.User, "u") + } + if len(got.Domains) != 1 { + t.Errorf("Domains: got %d, want 1", len(got.Domains)) + } + if len(got.Mailboxes) != 1 { + t.Errorf("Mailboxes: got %d, want 1", len(got.Mailboxes)) + } + if len(got.Databases) != 1 { + t.Errorf("Databases: got %d, want 1", len(got.Databases)) + } + if len(got.Warnings) != 1 { + t.Errorf("Warnings: got %d, want 1", len(got.Warnings)) + } +} + +func TestEmptyInventoryNoNulls(t *testing.T) { + inv := NewEmptyInventory("user", "192.0.2.1", "source") + b, err := json.Marshal(inv) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + s := string(b) + for _, field := range []string{"domains", "mailboxes", "databases", "warnings", "zones", "jobs", "environment", "items"} { + if strings.Contains(s, `"`+field+`":null`) { + t.Errorf("%s is null, want empty array", field) + } + } +} diff --git a/internal/accountinventory/write.go b/internal/accountinventory/write.go new file mode 100644 index 00000000..7ed26de6 --- /dev/null +++ b/internal/accountinventory/write.go @@ -0,0 +1,338 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +func WriteInventoryJSON(path string, inv NormalizedInventory) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + b, err := json.MarshalIndent(inv, "", " ") + if err != nil { + return fmt.Errorf("accountinventory: marshal: %w", err) + } + b = append(b, '\n') + return os.WriteFile(path, b, 0o600) +} + +func AggregateWarnings(result CollectResult) []string { + var all []string + for _, w := range result.Source.Warnings { + all = append(all, "source: "+w) + } + if result.Dest != nil { + for _, w := range result.Dest.Warnings { + all = append(all, "destination: "+w) + } + } + if len(all) == 0 { + return []string{} + } + return all +} + +func WriteReport(path string, result CollectResult) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("accountinventory: mkdir %s: %w", filepath.Dir(path), err) + } + var sb strings.Builder + writeInventorySection(&sb, result.Source, "Source") + if result.Dest != nil { + sb.WriteString("\n---\n\n") + writeInventorySection(&sb, *result.Dest, "Destination") + } + return os.WriteFile(path, []byte(sb.String()), 0o600) +} + +func writeDNSSection(sb *strings.Builder, dns DNSSection) { + totalRecords := 0 + for _, z := range dns.Zones { + totalRecords += len(z.Records) + } + + writeConfigSection(sb, "DNS Zones", dns.ConfigSection, len(dns.Zones)) + + for _, z := range dns.Zones { + status := "available" + if !z.Available { + status = "unavailable" + } + fmt.Fprintf(sb, "### %s - %s via %s (%d records)\n\n", z.Zone, status, z.Method, len(z.Records)) + for _, w := range z.Warnings { + fmt.Fprintf(sb, "> **Warning**: %s\n\n", w) + } + if len(z.Records) > 0 { + sb.WriteString("| Type | Name | TTL | Value |\n") + sb.WriteString("|------|------|-----|-------|\n") + for _, r := range z.Records { + val := r.Value + if len(val) > 60 { + val = val[:57] + "..." + } + fmt.Fprintf(sb, "| %s | %s | %d | %s |\n", r.Type, r.Name, r.TTL, val) + } + sb.WriteString("\n") + } + } +} + +// mdCell makes an arbitrary string safe inside a Markdown table cell: +// pipes are escaped and CR/LF collapsed to spaces (DNS TXT values are +// attacker-influenced free text and must not break out of their cell), +// then the result is truncated rune-safely. +var mdCellReplacer = strings.NewReplacer("|", "\\|", "\n", " ", "\r", " ") + +func mdCell(s string, max int) string { + if runes := []rune(s); len(runes) > max { + s = string(runes[:max-3]) + "..." + } + return mdCellReplacer.Replace(s) +} + +func writeCronSection(sb *strings.Builder, cron CronSection) { + status := "available" + if !cron.Available { + status = "unavailable" + } + fmt.Fprintf(sb, "## Cron Jobs (%d) - %s via %s\n\n", len(cron.Jobs), status, cron.SourceCommand) + for _, w := range cron.Warnings { + fmt.Fprintf(sb, "> **Warning**: %s\n\n", w) + } + for _, e := range cron.Errors { + fmt.Fprintf(sb, "> **Error**: %s\n\n", e) + } + if cron.CommentsCount > 0 || cron.DisabledJobsCount > 0 { + fmt.Fprintf(sb, "- Comments: %d - Disabled jobs: %d\n\n", cron.CommentsCount, cron.DisabledJobsCount) + } + if len(cron.Environment) > 0 { + sb.WriteString("| Env Var | Value (redacted) |\n") + sb.WriteString("|---------|------------------|\n") + for _, e := range cron.Environment { + fmt.Fprintf(sb, "| %s | %s |\n", mdCell(e.Name, 40), mdCell(e.ValueRedacted, 60)) + } + sb.WriteString("\n") + } + if len(cron.Jobs) > 0 { + sb.WriteString("| Schedule | Command (redacted) | Enabled |\n") + sb.WriteString("|----------|--------------------|---------|\n") + for _, j := range cron.Jobs { + schedule := j.Macro + if j.Type == "schedule" { + schedule = fmt.Sprintf("%s %s %s %s %s", j.Minute, j.Hour, j.DayOfMonth, j.Month, j.DayOfWeek) + } + enabled := "yes" + if !j.Enabled { + enabled = "no" + } + fmt.Fprintf(sb, "| %s | %s | %s |\n", mdCell(schedule, 40), mdCell(j.CommandRedacted, 60), enabled) + } + sb.WriteString("\n") + } +} + +func writeConfigSection(sb *strings.Builder, title string, sec ConfigSection, count int) { + status := "available" + if !sec.Available { + status = "unavailable" + } + fmt.Fprintf(sb, "## %s (%d) - %s via %s\n\n", title, count, status, sec.SourceFunction) + for _, w := range sec.Warnings { + fmt.Fprintf(sb, "> **Warning**: %s\n\n", w) + } +} + +func writeInventorySection(sb *strings.Builder, inv NormalizedInventory, title string) { + fmt.Fprintf(sb, "# Account Inventory - %s\n\n", title) + fmt.Fprintf(sb, "- **User**: %s\n", inv.Account.User) + fmt.Fprintf(sb, "- **Host**: %s\n", inv.Account.Host) + fmt.Fprintf(sb, "- **Collected**: %s\n\n", inv.Account.CollectedAt) + + fmt.Fprintf(sb, "## Domains (%d)\n\n", len(inv.Domains)) + if len(inv.Domains) > 0 { + sb.WriteString("| Domain | Type | Document Root |\n") + sb.WriteString("|--------|------|---------------|\n") + for _, d := range inv.Domains { + root := d.DocumentRoot + if root == "" { + root = "-" + } + fmt.Fprintf(sb, "| %s | %s | %s |\n", d.Name, d.Type, root) + } + sb.WriteString("\n") + } + + fmt.Fprintf(sb, "## Mailboxes (%d)\n\n", len(inv.Mailboxes)) + if len(inv.Mailboxes) > 0 { + sb.WriteString("| Email | Domain | Disk Usage (bytes) |\n") + sb.WriteString("|-------|--------|--------------------|\n") + for _, m := range inv.Mailboxes { + fmt.Fprintf(sb, "| %s | %s | %d |\n", m.Email, m.Domain, m.DiskUsage) + } + sb.WriteString("\n") + } + + fmt.Fprintf(sb, "## Forwarders (%d)\n\n", len(inv.Forwarders)) + if len(inv.Forwarders) > 0 { + sb.WriteString("| Source | Destination | Domain |\n") + sb.WriteString("|--------|-------------|--------|\n") + for _, f := range inv.Forwarders { + fmt.Fprintf(sb, "| %s | %s | %s |\n", f.Source, f.Destination, f.Domain) + } + sb.WriteString("\n") + } + + fmt.Fprintf(sb, "## Autoresponders (%d)\n\n", len(inv.Autoresponders)) + if len(inv.Autoresponders) > 0 { + sb.WriteString("| Email | Domain | Subject | Interval (h) | From | HTML | Body |\n") + sb.WriteString("|-------|--------|---------|---------------|------|------|------|\n") + for _, a := range inv.Autoresponders { + body := "(not collected)" + html := "" + if a.BodyCollected { + body = mdCell(a.Body, 120) + html = "no" + if a.IsHTML != 0 { + html = "yes" + } + } + fmt.Fprintf(sb, "| %s | %s | %s | %d | %s | %s | %s |\n", + a.Email, a.Domain, mdCell(a.Subject, 80), a.Interval, mdCell(a.From, 40), html, body) + } + sb.WriteString("\n") + } + + fmt.Fprintf(sb, "## Databases (%d)\n\n", len(inv.Databases)) + if len(inv.Databases) > 0 { + sb.WriteString("| Database | Disk Usage (bytes) | Users |\n") + sb.WriteString("|----------|--------------------|-------|\n") + for _, db := range inv.Databases { + users := strings.Join(db.Users, ", ") + if users == "" { + users = "-" + } + fmt.Fprintf(sb, "| %s | %d | %s |\n", db.Name, db.DiskUsage, users) + } + sb.WriteString("\n") + } + + writeConfigSection(sb, "FTP Accounts", inv.FTP.ConfigSection, len(inv.FTP.Items)) + if len(inv.FTP.Items) > 0 { + sb.WriteString("| Login | Type | Directory | Disk Used (MB) |\n") + sb.WriteString("|-------|------|-----------|----------------|\n") + for _, f := range inv.FTP.Items { + fmt.Fprintf(sb, "| %s | %s | %s | %d |\n", f.Login, f.Type, f.Dir, f.DiskUsed) + } + sb.WriteString("\n") + } + + writeConfigSection(sb, "SSL Certificates", inv.SSL.ConfigSection, len(inv.SSL.Items)) + if len(inv.SSL.Items) > 0 { + sb.WriteString("| Domains | Issuer | Valid Until | Type |\n") + sb.WriteString("|---------|--------|------------|------|\n") + for _, s := range inv.SSL.Items { + fmt.Fprintf(sb, "| %s | %s | %d | %s |\n", s.Domains, s.Issuer, s.ValidUntil, s.ValidationType) + } + sb.WriteString("\n") + } + + writeConfigSection(sb, "PHP Versions", inv.PHP.ConfigSection, len(inv.PHP.Items)) + if len(inv.PHP.Items) > 0 { + sb.WriteString("| Domain | Version |\n") + sb.WriteString("|--------|---------|\n") + for _, p := range inv.PHP.Items { + fmt.Fprintf(sb, "| %s | %s |\n", p.Domain, p.Version) + } + sb.WriteString("\n") + } + + writeDNSSection(sb, inv.DNS) + writeCronSection(sb, inv.Cron) + + writeEmailRoutingSection(sb, inv.EmailRouting) + writeDefaultAddressSection(sb, inv.DefaultAddresses) + writeEmailFiltersSection(sb, inv.EmailFilters) + writeRedirectsSection(sb, inv.Redirects) + + if len(inv.Warnings) > 0 { + fmt.Fprintf(sb, "## Warnings (%d)\n\n", len(inv.Warnings)) + for _, w := range inv.Warnings { + fmt.Fprintf(sb, "- %s\n", w) + } + sb.WriteString("\n") + } +} + +func writeEmailRoutingSection(sb *strings.Builder, sec EmailRoutingSection) { + writeConfigSection(sb, "Email Routing", sec.ConfigSection, len(sec.Items)) + if len(sec.Items) == 0 { + return + } + sb.WriteString("| Domain | Routing | Detected | Always Accept | MX Records |\n") + sb.WriteString("|--------|---------|----------|---------------|------------|\n") + for _, e := range sec.Items { + mx := make([]string, 0, len(e.MXRecords)) + for _, m := range e.MXRecords { + mx = append(mx, fmt.Sprintf("%d %s", m.Priority, m.Exchange)) + } + fmt.Fprintf(sb, "| %s | %s | %s | %t | %s |\n", + e.Domain, e.Routing, e.Detected, e.AlwaysAccept, mdCell(strings.Join(mx, "; "), 80)) + } + sb.WriteString("\n") +} + +func writeDefaultAddressSection(sb *strings.Builder, sec DefaultAddressSection) { + writeConfigSection(sb, "Default Addresses", sec.ConfigSection, len(sec.Items)) + if len(sec.Items) == 0 { + return + } + sb.WriteString("| Domain | Default Address |\n") + sb.WriteString("|--------|-----------------|\n") + for _, e := range sec.Items { + fmt.Fprintf(sb, "| %s | %s |\n", e.Domain, mdCell(e.DefaultAddress, 80)) + } + sb.WriteString("\n") +} + +func writeEmailFiltersSection(sb *strings.Builder, sec EmailFilterSection) { + writeConfigSection(sb, "Email Filters", sec.ConfigSection, len(sec.Items)) + if len(sec.Items) == 0 { + return + } + sb.WriteString("| Account | Filter | Enabled | Rules | Actions |\n") + sb.WriteString("|---------|--------|---------|-------|--------|\n") + for _, e := range sec.Items { + account := e.Account + if account == "" { + account = "(account-level)" + } + fmt.Fprintf(sb, "| %s | %s | %t | %d | %d |\n", + mdCell(account, 60), mdCell(e.FilterName, 60), e.Enabled, e.RuleCount, e.ActionCount) + } + sb.WriteString("\n") +} + +func writeRedirectsSection(sb *strings.Builder, sec RedirectSection) { + writeConfigSection(sb, "Redirects", sec.ConfigSection, len(sec.Items)) + if len(sec.Items) == 0 { + return + } + sb.WriteString("| Domain | Source | Destination | Kind | Type | Status |\n") + sb.WriteString("|--------|--------|-------------|------|------|--------|\n") + for _, e := range sec.Items { + status := "-" + if e.StatusCode != 0 { + status = fmt.Sprintf("%d", e.StatusCode) + } + fmt.Fprintf(sb, "| %s | %s | %s | %s | %s | %s |\n", + e.Domain, mdCell(e.Source, 60), mdCell(e.Destination, 60), e.Kind, e.Type, status) + } + sb.WriteString("\n") +} diff --git a/internal/accountinventory/write_test.go b/internal/accountinventory/write_test.go new file mode 100644 index 00000000..98e2cf11 --- /dev/null +++ b/internal/accountinventory/write_test.go @@ -0,0 +1,371 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package accountinventory + +import ( + "encoding/json" + "github.com/tis24dev/cPanel_self-migration/internal/cpanel" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteInventoryJSON(t *testing.T) { + dir := t.TempDir() + inv := NormalizedInventory{ + Account: AccountInfo{User: "u", Host: "h", CollectedAt: "t", Side: "source"}, + Domains: []DomainEntry{{Name: "d.com", Type: "main"}}, + Mailboxes: []MailboxEntry{}, + Databases: []DatabaseEntry{}, + Warnings: []string{}, + } + path := filepath.Join(dir, "inventory.json") + if err := WriteInventoryJSON(path, inv); err != nil { + t.Fatalf("WriteInventoryJSON: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var got NormalizedInventory + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if got.Account.User != "u" { + t.Errorf("user = %q", got.Account.User) + } + if len(got.Domains) != 1 { + t.Errorf("domains = %d", len(got.Domains)) + } +} + +func TestWriteInventoryJSONCreatesDir(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "inventory.json") + inv := NewEmptyInventory("u", "h", "source") + if err := WriteInventoryJSON(path, inv); err != nil { + t.Fatalf("WriteInventoryJSON: %v", err) + } + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Error("file not created") + } +} + +func TestWriteReport(t *testing.T) { + dir := t.TempDir() + result := CollectResult{ + Source: NormalizedInventory{ + Account: AccountInfo{User: "src", Host: "192.0.2.1", CollectedAt: "2026-07-01", Side: "source"}, + Domains: []DomainEntry{{Name: "main.example", Type: "main", DocumentRoot: "/home/src/public_html"}}, + Mailboxes: []MailboxEntry{{Email: "info@main.example", Domain: "main.example", User: "info"}}, + Databases: []DatabaseEntry{{Name: "src_wp", Users: []string{"src_admin"}}}, + Warnings: []string{"test warning"}, + }, + } + path := filepath.Join(dir, "report.md") + if err := WriteReport(path, result); err != nil { + t.Fatalf("WriteReport: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + s := string(b) + for _, want := range []string{ + "# Account Inventory", + "src", "192.0.2.1", + "main.example", "main", + "info@main.example", + "src_wp", + "test warning", + } { + if !strings.Contains(s, want) { + t.Errorf("report missing %q", want) + } + } +} + +func TestWriteReportWithForwarders(t *testing.T) { + dir := t.TempDir() + result := CollectResult{ + Source: NormalizedInventory{ + Account: AccountInfo{User: "u", Host: "h", CollectedAt: "t", Side: "source"}, + Domains: []DomainEntry{}, + Mailboxes: []MailboxEntry{}, + Databases: []DatabaseEntry{}, + Forwarders: []NormForwarderEntry{{Source: "info@d.com", Destination: "admin@gmail.com", Domain: "d.com"}}, + Autoresponders: []NormAutoresponderEntry{{Email: "info@d.com", Domain: "d.com", Subject: "OOO", Interval: 24}}, + Warnings: []string{}, + }, + } + path := filepath.Join(dir, "report.md") + if err := WriteReport(path, result); err != nil { + t.Fatalf("WriteReport: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + s := string(b) + for _, want := range []string{ + "Forwarders (1)", "info@d.com", "admin@gmail.com", + "Autoresponders (1)", "OOO", + } { + if !strings.Contains(s, want) { + t.Errorf("report missing %q", want) + } + } +} + +func TestAggregateWarnings(t *testing.T) { + tests := []struct { + name string + result CollectResult + wantLen int + contains []string + }{ + { + name: "source only with warnings", + result: CollectResult{ + Source: NormalizedInventory{ + Warnings: []string{"mail unavailable"}, + }, + }, + wantLen: 1, + contains: []string{"source: mail unavailable"}, + }, + { + name: "source + dest both with warnings", + result: CollectResult{ + Source: NormalizedInventory{ + Warnings: []string{"src warning"}, + }, + Dest: &NormalizedInventory{ + Warnings: []string{"dst warning 1", "dst warning 2"}, + }, + }, + wantLen: 3, + contains: []string{"source: src warning", "destination: dst warning 1", "destination: dst warning 2"}, + }, + { + name: "no warnings", + result: CollectResult{ + Source: NormalizedInventory{Warnings: []string{}}, + }, + wantLen: 0, + }, + { + name: "dest nil no crash", + result: CollectResult{ + Source: NormalizedInventory{Warnings: []string{"w"}}, + Dest: nil, + }, + wantLen: 1, + contains: []string{"source: w"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := AggregateWarnings(tt.result) + if len(got) != tt.wantLen { + t.Errorf("got %d warnings, want %d: %v", len(got), tt.wantLen, got) + } + for _, want := range tt.contains { + found := false + for _, w := range got { + if strings.Contains(w, want) { + found = true + break + } + } + if !found { + t.Errorf("warnings missing %q in %v", want, got) + } + } + }) + } +} + +func TestWriteReportWithDNS(t *testing.T) { + dir := t.TempDir() + result := CollectResult{ + Source: NormalizedInventory{ + Account: AccountInfo{User: "u", Host: "h", CollectedAt: "t", Side: "source"}, + Domains: []DomainEntry{}, + Mailboxes: []MailboxEntry{}, + Databases: []DatabaseEntry{}, + Forwarders: []NormForwarderEntry{}, + Autoresponders: []NormAutoresponderEntry{}, + FTP: FTPSection{ConfigSection: ConfigSection{Warnings: []string{}}, Items: []FTPEntry{}}, + SSL: SSLSection{ConfigSection: ConfigSection{Warnings: []string{}}, Items: []SSLEntry{}}, + PHP: PHPSection{ConfigSection: ConfigSection{Warnings: []string{}}, Items: []PHPEntry{}}, + DNS: DNSSection{ + ConfigSection: ConfigSection{Available: true, Method: "api2", SourceFunction: "ZoneEdit::fetchzone_records", Warnings: []string{}}, + Zones: []DNSZoneResult{ + { + Available: true, + Zone: "example.com", + Method: "api2", + SourceFunction: "ZoneEdit::fetchzone_records", + Records: []cpanel.DNSRecord{ + {Type: "A", Name: "example.com.", TTL: 14400, Value: "192.168.1.1", Address: "192.168.1.1"}, + {Type: "MX", Name: "example.com.", TTL: 14400, Value: "mail.example.com.", Exchange: "mail.example.com.", Priority: 10}, + }, + Warnings: []string{}, + Errors: []string{}, + }, + }, + }, + Warnings: []string{}, + }, + } + path := filepath.Join(dir, "report.md") + if err := WriteReport(path, result); err != nil { + t.Fatalf("WriteReport: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + s := string(b) + for _, want := range []string{ + "DNS Zones", "example.com", "api2", + "192.168.1.1", "mail.example.com.", + "| Type |", + } { + if !strings.Contains(s, want) { + t.Errorf("report missing %q", want) + } + } +} + +func TestWriteReportWithCron(t *testing.T) { + dir := t.TempDir() + inv := NewEmptyInventory("u", "h", "source") + inv.Cron = CronSection{ + Available: true, + Method: "ssh_crontab_l", + SourceCommand: "crontab -l", + CommentsCount: 2, + DisabledJobsCount: 1, + Environment: []CronEnvEntry{ + {Name: "MAILTO", ValueRedacted: "admin@example.com", LineNumber: 1}, + }, + Jobs: []CronJobEntry{ + {Type: "schedule", Minute: "0", Hour: "3", DayOfMonth: "*", Month: "*", DayOfWeek: "*", + CommandRedacted: "/bin/dump db | gzip > /b/db.gz", CommandSHA256: "sha256:aa", RawLineSHA256: "sha256:bb", + Enabled: true, LineNumber: 2, Warnings: []string{}}, + {Type: "macro", Macro: "@daily", + CommandRedacted: "/usr/bin/php /home/u/cron.php", CommandSHA256: "sha256:cc", RawLineSHA256: "sha256:dd", + Enabled: false, LineNumber: 3, Warnings: []string{}}, + }, + Warnings: []string{}, + Errors: []string{}, + } + result := CollectResult{Source: inv} + path := filepath.Join(dir, "report.md") + if err := WriteReport(path, result); err != nil { + t.Fatalf("WriteReport: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + s := string(b) + for _, want := range []string{ + "Cron Jobs (2)", "crontab -l", + "0 3 * * *", "@daily", + "MAILTO", + "Disabled jobs: 1", + "| yes |", "| no |", + } { + if !strings.Contains(s, want) { + t.Errorf("report missing %q", want) + } + } + if strings.Contains(s, "sha256:aa") { + t.Error("report should show redacted preview, not hashes") + } + // A pipe inside a command must be escaped, or it breaks the table row. + if !strings.Contains(s, `\|`) { + t.Error("pipe in command must be escaped in the markdown table") + } + if strings.Contains(s, " | gzip >") { + t.Errorf("unescaped pipe leaked into a table row") + } +} + +func TestWriteReportWithDest(t *testing.T) { + dir := t.TempDir() + dest := NormalizedInventory{ + Account: AccountInfo{User: "dst", Host: "192.0.2.2", Side: "destination"}, + Domains: []DomainEntry{}, + Mailboxes: []MailboxEntry{}, + Databases: []DatabaseEntry{}, + Warnings: []string{}, + } + result := CollectResult{ + Source: NewEmptyInventory("src", "198.51.100.1", "source"), + Dest: &dest, + } + path := filepath.Join(dir, "report.md") + if err := WriteReport(path, result); err != nil { + t.Fatalf("WriteReport: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + s := string(b) + if !strings.Contains(s, "Destination") { + t.Error("report missing destination section") + } + if !strings.Contains(s, "dst") { + t.Error("report missing dest user") + } +} + +// PR 2B-2: the autoresponder report table carries the collected content +// (from, html flag, body preview) and declares honestly when the body was +// NOT collected. +func TestWriteReportAutoresponderBodies(t *testing.T) { + dir := t.TempDir() + result := CollectResult{ + Source: NormalizedInventory{ + Account: AccountInfo{User: "u", Host: "h", CollectedAt: "t", Side: "source"}, + Domains: []DomainEntry{}, + Mailboxes: []MailboxEntry{}, + Databases: []DatabaseEntry{}, + Forwarders: []NormForwarderEntry{}, + Autoresponders: []NormAutoresponderEntry{ + {Email: "info@d.com", Domain: "d.com", Subject: "OOO", Interval: 8, + From: "Info Desk", Body: "On vacation.\nBack Monday.\n", IsHTML: 1, + Charset: "utf-8", BodyCollected: true}, + {Email: "old@d.com", Domain: "d.com", Subject: "Legacy", Interval: 0}, + }, + Warnings: []string{}, + }, + } + path := filepath.Join(dir, "report.md") + if err := WriteReport(path, result); err != nil { + t.Fatalf("WriteReport: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + s := string(b) + for _, want := range []string{ + "Autoresponders (2)", "Info Desk", "On vacation.", + "not collected", // the old@d.com row must declare the missing body + } { + if !strings.Contains(s, want) { + t.Errorf("report missing %q", want) + } + } + if strings.Contains(s, "On vacation.\nBack Monday") { + t.Error("body newline leaked raw into a markdown table cell (mdCell must collapse it)") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a1eda3df..9ce8199b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -133,7 +133,7 @@ func Load(path string) (Config, error) { // the run would silently do source-only analysis, with no migration and no // warning, looking as if it had "nothing to do". if cfg.destIntended() { - logx.Debug("config: destination block treated as intended (ip=%v ssh_user=%v ssh_pass=%v port=%d timeout=%v) — validating it", + logx.Debug("config: destination block treated as intended (ip=%v ssh_user=%v ssh_pass=%v port=%d timeout=%v), validating it", cfg.Dest.IP != "", cfg.Dest.SSHUser != "", cfg.Dest.SSHPass != "", cfg.Dest.Port, cfg.Dest.Timeout) if err := cfg.Dest.validate("dest"); err != nil { return Config{}, err diff --git a/internal/cpanel/api.go b/internal/cpanel/api.go index 23c50afd..fe6fbd54 100644 --- a/internal/cpanel/api.go +++ b/internal/cpanel/api.go @@ -67,22 +67,78 @@ func uapiArgsScript(module, fn string, args map[string]string) (string, map[stri sort.Strings(keys) for i, k := range keys { ev := fmt.Sprintf("ARG_%d", i) - env[ev] = args[k] + env[ev] = encodeUAPIArgValue(args[k]) // uapi key=value with the value taken from $ARG_i (quoted). fmt.Fprintf(&b, " %s=\"$%s\"", k, ev) } return b.String(), env } +// encodeUAPIArgValue percent-encodes a uapi argument VALUE so it survives +// cpsrvd's form-url-decoding of `uapi` CLI parameters. cpsrvd decodes the value +// it receives: '+' becomes a space and '%XX' becomes the decoded byte. On this +// tool the value that matters is the MySQL password of Mysql::create_user and +// set_password: a generated or reused DB password containing '+' or '%' would +// otherwise be stored DECODED, mismatched against the raw password written into +// wp-config and used via MYSQL_PWD, breaking the migrated site's DB auth while +// the API still reports success. The same decode corrupts a raw '+' in a +// DKIM/SPF/TXT payload. +// +// Only '%' and '+' are rewritten (in that order, so a literal '%' is not +// mistaken for the escape we just introduced). Every other byte (space, '&', +// '/', '=', which cpsrvd leaves untouched) passes through verbatim, so any value +// that contains neither '%' nor '+' is byte-identical to before this encoding +// existed and no currently-working call changes behavior. +// Verified live on this baseline via Mysql::create_user: a raw '+'/'%2B' +// password is stored decoded (literal fails to auth, pre-encoded form +// round-trips), matching an earlier DNS-path finding. +func encodeUAPIArgValue(v string) string { + if !strings.ContainsAny(v, "%+") { + return v + } + v = strings.ReplaceAll(v, "%", "%25") + v = strings.ReplaceAll(v, "+", "%2B") + return v +} + +// EncodeUAPIArgValue is the exported passthrough of encodeUAPIArgValue, so the +// account-inventory batch prefetch can pre-encode a uapi argument VALUE +// byte-identically to RunUAPI before splicing it into a server-side loop's env. +// It applies ONLY to the uapi path (matching uapiArgsScript); the cpapi2 path +// does not encode (see api2ArgsScript). +func EncodeUAPIArgValue(v string) string { return encodeUAPIArgValue(v) } + // RunUAPI executes a UAPI call on the host and unmarshals result.data into T. // It returns an error if the SSH command fails, the JSON is unparseable, or // the UAPI status is not success (1), including any reported error messages. func RunUAPI[T any](ctx context.Context, c Runner, module, fn string, args map[string]string) (T, error) { + data, _, err := runUAPIExec[T](ctx, c, module, fn, args) + return data, err +} + +// RunUAPIRaw is RunUAPI returning ALSO the verbatim response bytes. It +// exists for the email-apply pre-write backup, which archives the raw +// server state alongside the normalized entries (2B design). Callers must +// only use it for responses that carry no secret (the raw bytes typically +// end up in an artifact file). RunUAPI, RunAPI2 and RunUAPIRaw are the PUBLIC +// entry points, and their module/function arguments MUST be plain string +// literals. +func RunUAPIRaw[T any](ctx context.Context, c Runner, module, fn string, args map[string]string) (T, []byte, error) { + return runUAPIExec[T](ctx, c, module, fn, args) +} + +// runUAPIExec is the shared body of RunUAPI/RunUAPIRaw. It is +// deliberately package-private: the literal-names structural guard checks +// the PUBLIC entry points, and this helper receives names those entry +// points already exposed to the guard. An in-package call with a +// dynamically built name would be the same (accepted, human-reviewed) +// residual surface as a hand-built Runner.RunScript snippet. +func runUAPIExec[T any](ctx context.Context, c Runner, module, fn string, args map[string]string) (T, []byte, error) { var zero T script, env := uapiArgsScript(module, fn, args) out, err := c.RunScript(ctx, script, env) if err != nil { - return zero, fmt.Errorf("%s::%s: %w", module, fn, err) + return zero, nil, fmt.Errorf("%s::%s: %w", module, fn, err) } logx.Debug("%s::%s: UAPI call succeeded (%d bytes response)", module, fn, len(out)) // Opt-in, OFF by default (see debug.go): the normal path logs only the length @@ -93,7 +149,77 @@ func RunUAPI[T any](ctx context.Context, c Runner, module, fn string, args map[s if rawResponseDebug { logx.Debug("%s::%s: raw response (secrets redacted): %s", module, fn, redactJSONForDebug(out)) } - return parseUAPI[T](module, fn, out) + data, err := parseUAPI[T](module, fn, out) + if err != nil { + return zero, nil, err + } + return data, out, nil +} + +// api2ArgsScript builds a tiny bash snippet that invokes `cpapi2` with the +// given module/function and arg keys, analogous to uapiArgsScript but for the +// legacy cPanel API2 CLI. Used for API2 calls that have no UAPI equivalent, +// e.g. ZoneEdit::fetchzone_records (read, cPanel < v136). +// +// NOTE: unlike uapiArgsScript, this does NOT apply encodeUAPIArgValue. Whether +// cpsrvd form-url-decodes cpapi2 CLI values (the '+'->space / '%XX'->byte +// corruption fixed for uapi) has NOT been verified empirically for the cpapi2 +// path, and every value passed here today is either an enum (local|remote|auto| +// secondary) or a domain name, none can contain '+' or '%', so there is no +// active corruption to fix. If a future api2 caller passes a free-form value, +// verify the cpapi2 decode behavior on a live host and extend the encoding here. +func api2ArgsScript(module, fn string, args map[string]string) (string, map[string]string) { + env := map[string]string{} + var b strings.Builder + b.WriteString("cpapi2 --output=json ") + b.WriteString(module) + b.WriteString(" ") + b.WriteString(fn) + keys := make([]string, 0, len(args)) + for k := range args { + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + ev := fmt.Sprintf("ARG_%d", i) + env[ev] = args[k] + fmt.Fprintf(&b, " %s=\"$%s\"", k, ev) + } + return b.String(), env +} + +// RunAPI2 executes an API2 call via the cpapi2 CLI on the host and unmarshals +// cpanelresult.data into T. It returns an error if the SSH command fails, the +// JSON is unparseable, or the API2 event.result is not 1. +func RunAPI2[T any](ctx context.Context, c Runner, module, fn string, args map[string]string) (T, error) { + var zero T + script, env := api2ArgsScript(module, fn, args) + out, err := c.RunScript(ctx, script, env) + if err != nil { + return zero, fmt.Errorf("cpapi2 %s::%s: %w", module, fn, err) + } + logx.Debug("cpapi2 %s::%s: API2 call succeeded (%d bytes response)", module, fn, len(out)) + if rawResponseDebug { + logx.Debug("cpapi2 %s::%s: raw response (secrets redacted): %s", module, fn, redactJSONForDebug(out)) + } + return parseAPI2[T](module, fn, out) +} + +// parseAPI2 is the pure parsing half of RunAPI2, exposed for unit testing. +func parseAPI2[T any](module, fn string, out []byte) (T, error) { + var zero T + var env api2Envelope[T] + if err := json.Unmarshal(out, &env); err != nil { + return zero, fmt.Errorf("cpapi2 %s::%s: parse JSON (%d bytes): %w", module, fn, len(out), err) + } + if env.CPanelResult.Event.Result.String() != "1" { + errMsg := env.CPanelResult.Error + if errMsg == "" { + errMsg = "unknown API2 error" + } + return zero, fmt.Errorf("cpapi2 %s::%s: event.result=%s error=%s", module, fn, env.CPanelResult.Event.Result, errMsg) + } + return env.CPanelResult.Data, nil } // parseUAPI is the pure parsing half of RunUAPI, exposed for unit testing diff --git a/internal/cpanel/cron.go b/internal/cpanel/cron.go new file mode 100644 index 00000000..fdbdbefc --- /dev/null +++ b/internal/cpanel/cron.go @@ -0,0 +1,372 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" + "github.com/tis24dev/cPanel_self-migration/internal/redact" +) + +// CronEnvVar is one environment assignment line of a crontab (MAILTO=..., +// PATH=...). The value is redacted before it is stored anywhere if the +// variable name looks sensitive. +type CronEnvVar struct { + Name string + ValueRedacted string + ValueRaw string + LineNumber int +} + +// CronJob is one normalized crontab entry. Both hashes are computed over +// the REDACTED text, never the raw one: a hash of a raw command whose +// structure is visible in CommandRedacted would be an offline brute-force +// oracle for the masked secret. Drift comparison across runs still works, +// the same redaction yields the same hash. +// +// CommandRaw (2A): the un-redacted command, carried alongside the +// redacted form for the cron apply writer (the writer installs the RAW +// command via `crontab -`; redacted commands are not installable). +// RawLine (2A): the full original crontab line (schedule + command). +type CronJob struct { + Type string // "schedule" | "macro" + Minute string + Hour string + DayOfMonth string + Month string + DayOfWeek string + Macro string // "@daily", "@reboot", ... + CommandRedacted string + CommandRaw string + CommandSHA256 string // "sha256:" + RawLineSHA256 string // "sha256:" + RawLine string + Enabled bool // false for commented-out jobs + LineNumber int + Warnings []string +} + +// CrontabResult is the parsed content of one user crontab. +type CrontabResult struct { + Jobs []CronJob + Environment []CronEnvVar + CommentsCount int + DisabledJobsCount int + Warnings []string +} + +func newCrontabResult() CrontabResult { + return CrontabResult{ + Jobs: []CronJob{}, + Environment: []CronEnvVar{}, + Warnings: []string{}, + } +} + +// --------------------------------------------------------------------------- +// Fetch (read-only: the only crontab invocation is `crontab -l`) +// --------------------------------------------------------------------------- + +// crontabScript reads the user crontab. `crontab -l` legitimately exits 1 +// when the user has no crontab, and Runner.RunScript treats any non-zero +// exit as an error, so the script always exits 0 and carries the real +// exit code in a trailing marker LINE (`__CRONTAB_RC:__`, alone at +// column 0) that FetchCrontab strips and classifies. +const crontabScript = `out=$(crontab -l 2>&1); rc=$?; printf '%s\n__CRONTAB_RC:%d__\n' "$out" "$rc"` + +// cronRCLineRE matches the marker ONLY as a standalone line, so a crontab +// entry that happens to print the marker text inline cannot spoof it. +var cronRCLineRE = regexp.MustCompile(`^__CRONTAB_RC:([0-9]+)__$`) + +// FetchCrontab reads and parses the account crontab. "no crontab for user" +// is NOT an error: it returns an empty result with a light warning. Any +// other failure (SSH, permissions) is returned as an error. +func FetchCrontab(ctx context.Context, c Runner) (CrontabResult, error) { + out, err := c.RunScript(ctx, crontabScript, nil) + if err != nil { + return CrontabResult{}, fmt.Errorf("crontab -l: %w", err) + } + content, rc, err := splitCronMarker(string(out)) + if err != nil { + return CrontabResult{}, err + } + if rc == 0 { + res := ParseCrontab(content) + logx.Debug("FetchCrontab: %d job(s), %d env var(s), %d comment(s)", + len(res.Jobs), len(res.Environment), res.CommentsCount) + return res, nil + } + if strings.Contains(strings.ToLower(content), "no crontab") { + res := newCrontabResult() + res.Warnings = append(res.Warnings, "no crontab installed for this user (empty)") + return res, nil + } + // Real crontab failure (permissions, missing binary). The message is + // normally crontab's own stderr, but 2>&1 merges streams: run it + // through the redactor anyway before it can reach warnings/reports. + msg := RedactCronCommand(content) + if runes := []rune(msg); len(runes) > 200 { + msg = string(runes[:200]) + "..." + } + return CrontabResult{}, fmt.Errorf("crontab -l failed (rc=%d): %s", rc, msg) +} + +// splitCronMarker separates the crontab content from the trailing +// __CRONTAB_RC:__ marker line emitted by crontabScript. Only the LAST +// line matching the strict marker format is accepted; earlier occurrences +// of the marker text inside job commands stay part of the content. +func splitCronMarker(out string) (content string, rc int, err error) { + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + last := len(lines) - 1 + if last < 0 { + return "", 0, fmt.Errorf("crontab -l: empty output, RC marker missing") + } + m := cronRCLineRE.FindStringSubmatch(lines[last]) + if m == nil { + return "", 0, fmt.Errorf("crontab -l: RC marker missing in output (%d bytes)", len(out)) + } + rc, convErr := strconv.Atoi(m[1]) + if convErr != nil { + return "", 0, fmt.Errorf("crontab -l: unreadable RC marker: %w", convErr) + } + return strings.Join(lines[:last], "\n"), rc, nil +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +var ( + // minute / hour / day-of-month are strictly numeric expressions; month + // and day-of-week also accept names (jan, mon, ...). The strict numeric + // rule keeps prose comments ("# 5 minuti dopo ogni ora...") from being + // misclassified as disabled jobs. + cronNumericField = regexp.MustCompile(`^[0-9*,/-]+$`) + cronNamedField = regexp.MustCompile(`^[0-9A-Za-z*,/-]+$`) + cronEnvLine = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$`) +) + +var cronMacros = map[string]bool{ + "@reboot": true, "@yearly": true, "@annually": true, "@monthly": true, + "@weekly": true, "@daily": true, "@midnight": true, "@hourly": true, +} + +// ParseCrontab parses raw `crontab -l` output into normalized jobs, +// environment assignments and counters. It never fails: unparsable lines +// become warnings that reference the line only by number and hash (the raw +// content is never stored). +func ParseCrontab(raw string) CrontabResult { + res := newCrontabResult() + for i, line := range strings.Split(raw, "\n") { + lineNo := i + 1 + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + if strings.HasPrefix(trimmed, "#") { + body := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) + if job, ok := tryParseJob(body); ok { + job.Enabled = false + job.LineNumber = lineNo + job.RawLineSHA256 = sha256Tag(RedactCronCommand(line)) + job.RawLine = line + res.Jobs = append(res.Jobs, job) + res.DisabledJobsCount++ + } else { + res.CommentsCount++ + } + continue + } + + if m := cronEnvLine.FindStringSubmatch(trimmed); m != nil { + name, rawValue := m[1], m[2] + var redactedValue string + if isSensitiveCronName(name) { + redactedValue = redactedCronPlaceholder + } else { + redactedValue = RedactCronCommand(rawValue) + } + res.Environment = append(res.Environment, CronEnvVar{ + Name: name, ValueRedacted: redactedValue, ValueRaw: rawValue, LineNumber: lineNo, + }) + continue + } + + if job, ok := tryParseJob(trimmed); ok { + job.Enabled = true + job.LineNumber = lineNo + job.RawLineSHA256 = sha256Tag(RedactCronCommand(line)) + job.RawLine = trimmed + res.Jobs = append(res.Jobs, job) + continue + } + + res.Warnings = append(res.Warnings, + fmt.Sprintf("line %d unparsable (%s)", lineNo, sha256Tag(RedactCronCommand(line)))) + } + return res +} + +// tryParseJob attempts to parse one line as a macro or 5-field schedule +// job. Enabled/LineNumber/RawLineSHA256 are left for the caller to set. +func tryParseJob(line string) (CronJob, bool) { + if strings.HasPrefix(line, "@") { + sp := strings.IndexAny(line, " \t") + if sp < 0 { + return CronJob{}, false + } + macro := line[:sp] + command := strings.TrimSpace(line[sp:]) + if !cronMacros[macro] || command == "" { + return CronJob{}, false + } + redacted := RedactCronCommand(command) + return CronJob{ + Type: "macro", + Macro: macro, + CommandRedacted: redacted, + CommandRaw: command, + CommandSHA256: sha256Tag(redacted), + Warnings: []string{}, + }, true + } + + fields, command, ok := splitScheduleFields(line) + if !ok { + return CronJob{}, false + } + for i := 0; i < 3; i++ { + if !cronNumericField.MatchString(fields[i]) { + return CronJob{}, false + } + } + for i := 3; i < 5; i++ { + if !cronNamedField.MatchString(fields[i]) { + return CronJob{}, false + } + } + redacted := RedactCronCommand(command) + return CronJob{ + Type: "schedule", + Minute: fields[0], + Hour: fields[1], + DayOfMonth: fields[2], + Month: fields[3], + DayOfWeek: fields[4], + CommandRedacted: redacted, + CommandRaw: command, + CommandSHA256: sha256Tag(redacted), + Warnings: []string{}, + }, true +} + +// splitScheduleFields extracts the 5 schedule fields and returns the rest +// of the line verbatim as the command (pipes, quotes and redirects intact). +func splitScheduleFields(line string) (fields [5]string, command string, ok bool) { + rest := line + for i := 0; i < 5; i++ { + rest = strings.TrimLeft(rest, " \t") + sp := strings.IndexAny(rest, " \t") + if sp < 0 { + return fields, "", false + } + fields[i] = rest[:sp] + rest = rest[sp:] + } + command = strings.TrimSpace(rest) + if command == "" { + return fields, "", false + } + return fields, command, true +} + +func sha256Tag(s string) string { + h := sha256.Sum256([]byte(s)) + return "sha256:" + hex.EncodeToString(h[:]) +} + +// --------------------------------------------------------------------------- +// Redaction +// --------------------------------------------------------------------------- + +const redactedCronPlaceholder = "[REDACTED]" + +// cronSensitiveNameFragments is the shared superset (redact.Fragments()). +// Cron redaction now also catches cookie/session (previously missed) in addition +// to its own secure/pwd. The command-line flag regex additionally treats a +// "--user" flag (--user admin:pw) as sensitive; "user" is NOT in the shared key +// list because as a map/JSON key it is a legitimate non-secret field. +var cronSensitiveNameFragments = redact.Fragments() + +// cronFragAlt joins the shared fragments into a regexp-quoted alternation, so +// the command-line redactor stays in lockstep with the key-based redactors +// (one source of truth, no drift like the old secure= leak). QuoteMeta is +// defensive: current fragments are literal, but this guards future additions. +func cronFragAlt(extra ...string) string { + frags := append(redact.Fragments(), extra...) + for i, f := range frags { + frags[i] = regexp.QuoteMeta(f) + } + return strings.Join(frags, "|") +} + +func isSensitiveCronName(name string) bool { + lower := strings.ToLower(name) + for _, frag := range cronSensitiveNameFragments { + if strings.Contains(lower, frag) { + return true + } + } + return false +} + +var ( + // Everything between scheme:// and the LAST @ before the path is + // userinfo, greedy on purpose, so a password containing '@' + // (ftp://u:sec@ret@host) and single-token credentials + // (https://ghp_xxx@github.com) are both fully masked. Bare + // user@host without a scheme is NOT matched: that shape is also an + // email address, which must stay readable (MAILTO, mail -s). + cronURLCredsRE = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://)[^/\s]+@`) + // name=value where name contains a sensitive fragment; the value stops + // at whitespace, &, or a quote so surrounding syntax survives. The + // separator is strictly '=', a bare space would eat innocent arguments + // ("ssh-keygen -f ..." must survive intact). + cronKeyValueRE = regexp.MustCompile( + `(?i)([A-Za-z0-9_-]*(?:` + cronFragAlt() + `)[A-Za-z0-9_-]*=\s*)("[^"]*"|'[^']*'|[^&\s"']+)`) + // Sensitive FLAGS also accept a space-separated value (--password X, + // --user admin:pw). The (^|\s) anchor pins the dash to the start of a + // token: without it, the "-keygen" inside "ssh-keygen" would match and + // eat the following argument. + cronFlagValueRE = regexp.MustCompile( + `(?i)((?:^|\s)--?[A-Za-z0-9_-]*(?:` + cronFragAlt("user") + `)[A-Za-z0-9_-]*[= ]\s*)("[^"]*"|'[^']*'|[^&\s"']+)`) + // MySQL-style concatenated -p (mysqldump -pSECRET). Over- + // matches other tools' -p (ssh -p2222), accepted trade-off. + // MUST run before cronFlagValueRE: a password containing "pass" would + // otherwise be misread by that regex as a flag NAME, redacting the + // following innocent argument instead of the secret itself. + cronDashPRE = regexp.MustCompile(`(^|\s)-p([^\s"']+)`) + // Bearer/Basic tokens in inline headers. + cronBearerRE = regexp.MustCompile(`(?i)\b(bearer|basic)([ :]+)[^\s"']+`) +) + +// RedactCronCommand masks credentials embedded in a crontab command line +// while leaving the command structure readable. Applied BEFORE anything is +// stored or hashed, the raw command never leaves this function's callers. +func RedactCronCommand(cmd string) string { + out := cronURLCredsRE.ReplaceAllString(cmd, "${1}"+redactedCronPlaceholder+"@") + out = cronBearerRE.ReplaceAllString(out, "${1}${2}"+redactedCronPlaceholder) + out = cronDashPRE.ReplaceAllString(out, "${1}-p"+redactedCronPlaceholder) + out = cronFlagValueRE.ReplaceAllString(out, "${1}"+redactedCronPlaceholder) + out = cronKeyValueRE.ReplaceAllString(out, "${1}"+redactedCronPlaceholder) + return out +} diff --git a/internal/cpanel/cron_apply.go b/internal/cpanel/cron_apply.go new file mode 100644 index 00000000..33c2f94a --- /dev/null +++ b/internal/cpanel/cron_apply.go @@ -0,0 +1,62 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "fmt" + "strings" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +// Cron write primitives (PR 2A), the crontab writer. Called ONLY by +// the `cron apply` subcommand, exclusively against the DESTINATION host. +// The write primitive is SSH `crontab -` which replaces the entire +// crontab from stdin. Byte-verified in PR2A_PRE_CAPTURES.md. + +// InstallCrontab replaces the user crontab with the given content via +// `printf '%s' "$CONTENT" | crontab -` (2A-pre fact 1). The content is +// passed as an environment variable to avoid shell injection. +// WARNING: This is a WHOLE-CRONTAB replacement: callers must read the current +// crontab, merge the planned changes, and install the merged result. +func InstallCrontab(ctx context.Context, c Runner, content string) error { + out, err := c.RunScript(ctx, + `printf '%s' "$CRONTAB_CONTENT" | crontab - 2>&1; echo "RC=$?"`, + map[string]string{"CRONTAB_CONTENT": content}) + if err != nil { + return fmt.Errorf("crontab install: %w", err) + } + outStr := strings.TrimSpace(string(out)) + if !strings.HasSuffix(outStr, "RC=0") { + // crontab may echo the offending job line (with an inline credential) + // on a parse error; redact before it reaches stderr/logs. + return fmt.Errorf("crontab install: unexpected output %q", RedactCronCommand(outStr)) + } + logx.Debug("InstallCrontab: installed %d bytes", len(content)) + return nil +} + +// ReadCrontabRaw reads the user crontab as raw text via `crontab -l`. +// Returns the raw content and nil on success, empty string + nil when +// there is no crontab, or empty string + error on failure. Unlike +// FetchCrontab, this returns the VERBATIM text (no parsing, no +// redaction), needed for the crontab merge/install cycle. +func ReadCrontabRaw(ctx context.Context, c Runner) (string, error) { + out, err := c.RunScript(ctx, crontabScript, nil) + if err != nil { + return "", fmt.Errorf("crontab -l raw: %w", err) + } + content, rc, err := splitCronMarker(string(out)) + if err != nil { + return "", err + } + if rc != 0 { + if strings.Contains(strings.ToLower(content), "no crontab") { + return "", nil + } + return "", fmt.Errorf("crontab -l failed (rc=%d)", rc) + } + return content, nil +} diff --git a/internal/cpanel/cron_safety_test.go b/internal/cpanel/cron_safety_test.go new file mode 100644 index 00000000..c53c84c4 --- /dev/null +++ b/internal/cpanel/cron_safety_test.go @@ -0,0 +1,181 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// cronWritePatternsForbidden are crontab write patterns. `crontab -r` +// is UNCONDITIONALLY forbidden (no allowlist). The pipe patterns +// (`| crontab`, `crontab <`) are allowed ONLY in the writer file +// (cron_apply.go), the per-file allowlist was added consciously in 2A. +var cronWritePatternsForbidden = []string{ + "crontab -e", + "crontab -i", + "crontab <", + "| crontab", + "> crontab", + "crontab /", + "crontab $", +} + +var cronWritePatternsNoAllowlist = []string{ + "crontab -r", +} + +// cronWritePatternsAllowlist: ONLY files that may contain crontab write +// patterns. Amending this list is a conscious, reviewed act. Each entry +// must correspond to an existing file (a dangling entry would silently +// open a hole if someone later creates a file with that name). +var cronWritePatternsAllowlist = map[string]bool{ + "internal/cpanel/cron_apply.go": true, + "cmd/cpanel-self-migration/cron_apply_cmd.go": true, +} + +// TestNoCronWritePatterns asserts crontab write patterns are absent from +// all source except the allowlisted writer files. `crontab -r` is +// forbidden EVERYWHERE (no allowlist, the tool never removes a crontab). +func TestNoCronWritePatterns(t *testing.T) { + root := "../.." + rootAbs, err := filepath.Abs(root) + if err != nil { + t.Fatal(err) + } + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if d.Name() == ".git" || d.Name() == "vendor" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + abs, err := filepath.Abs(path) + if err != nil { + return err + } + rel, err := filepath.Rel(rootAbs, abs) + if err != nil { + return err + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + src := string(b) + for _, pattern := range cronWritePatternsNoAllowlist { + if strings.Contains(src, pattern) { + t.Errorf("%s contains unconditionally forbidden cron pattern %q", filepath.ToSlash(rel), pattern) + } + } + if cronWritePatternsAllowlist[filepath.ToSlash(rel)] { + return nil + } + for _, pattern := range cronWritePatternsForbidden { + if strings.Contains(src, pattern) { + t.Errorf("%s contains forbidden cron write pattern %q (allowlist: cron_apply.go only)", + filepath.ToSlash(rel), pattern) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } +} + +// TestCrontabScriptIsReadOnly pins the fetch script to the single read-only +// invocation: exactly one `crontab` occurrence, and it is `crontab -l`. +func TestCrontabScriptIsReadOnly(t *testing.T) { + if got := strings.Count(crontabScript, "crontab"); got != 1 { + t.Fatalf("crontabScript invokes crontab %d times, want exactly 1", got) + } + if !strings.Contains(crontabScript, "crontab -l") { + t.Fatal("crontabScript must invoke `crontab -l`") + } +} + +// cronWriteWrapperFns are the exported Go wrappers around the crontab +// write primitive. The string-pattern scan above is on the literal +// `crontab` shell token and never sees these CamelCase identifiers, so a +// call to InstallCrontab from a non-allowlisted file would slip past the +// guard (the same gap the DNS guard closes for MassEditZone*). ReadCrontabRaw +// is read-only (`crontab -l`) and is deliberately NOT listed. +var cronWriteWrapperFns = map[string]bool{ + "InstallCrontab": true, +} + +// TestNoCrontabWriteWrapperCallsOutsideAllowlist fails if any +// non-allowlisted production file calls the crontab write wrapper. +// Complements the string scan (which the CamelCase wrapper identifier +// evades) so the write path stays funneled through the reviewed allowlist. +func TestNoCrontabWriteWrapperCallsOutsideAllowlist(t *testing.T) { + root := "../.." // module root from internal/cpanel + fset := token.NewFileSet() + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if d.Name() == ".git" || d.Name() == "vendor" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if cronWritePatternsAllowlist[filepath.ToSlash(rel)] { + return nil + } + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if name := calleeBaseName(call.Fun); cronWriteWrapperFns[name] { + t.Errorf("%s: calls crontab write wrapper %s outside the allowlist; add the file to cronWritePatternsAllowlist only after review", + fset.Position(call.Pos()), name) + } + return true + }) + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } +} + +// TestCronWriteAllowlistFilesExist pins the allowlist against silent rot: +// an allowlisted path that no longer exists means the writer moved and the +// scan is guarding the wrong file (mirror of the DNS/email tests). +func TestCronWriteAllowlistFilesExist(t *testing.T) { + for rel := range cronWritePatternsAllowlist { + p := filepath.Join("../..", filepath.FromSlash(rel)) + if _, err := os.Stat(p); err != nil { + t.Errorf("allowlisted file %s does not exist: %v", rel, err) + } + } +} diff --git a/internal/cpanel/cron_test.go b/internal/cpanel/cron_test.go new file mode 100644 index 00000000..4c0f247f --- /dev/null +++ b/internal/cpanel/cron_test.go @@ -0,0 +1,433 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/redact" +) + +// --------------------------------------------------------------------------- +// Parser: schedules, macros, comments, env vars, invalid lines +// --------------------------------------------------------------------------- + +func TestParseCrontabStandardSchedule(t *testing.T) { + res := ParseCrontab("0 3 * * * /usr/local/bin/backup.sh\n*/5 * * * 1-5 /usr/bin/php /home/u/poll.php\n") + if len(res.Jobs) != 2 { + t.Fatalf("jobs = %d, want 2", len(res.Jobs)) + } + j := res.Jobs[0] + if j.Type != "schedule" { + t.Errorf("type = %q, want schedule", j.Type) + } + if j.Minute != "0" || j.Hour != "3" || j.DayOfMonth != "*" || j.Month != "*" || j.DayOfWeek != "*" { + t.Errorf("fields = %s %s %s %s %s", j.Minute, j.Hour, j.DayOfMonth, j.Month, j.DayOfWeek) + } + if !j.Enabled { + t.Error("job should be enabled") + } + if j.LineNumber != 1 { + t.Errorf("line = %d, want 1", j.LineNumber) + } + j2 := res.Jobs[1] + if j2.Minute != "*/5" || j2.DayOfWeek != "1-5" { + t.Errorf("second job fields: minute=%q dow=%q", j2.Minute, j2.DayOfWeek) + } +} + +func TestParseCrontabMacros(t *testing.T) { + res := ParseCrontab("@daily /usr/bin/php /home/u/daily.php\n@hourly /bin/cleanup\n@reboot /bin/startup\n") + if len(res.Jobs) != 3 { + t.Fatalf("jobs = %d, want 3", len(res.Jobs)) + } + wantMacros := []string{"@daily", "@hourly", "@reboot"} + for i, w := range wantMacros { + if res.Jobs[i].Type != "macro" { + t.Errorf("job %d type = %q, want macro", i, res.Jobs[i].Type) + } + if res.Jobs[i].Macro != w { + t.Errorf("job %d macro = %q, want %q", i, res.Jobs[i].Macro, w) + } + } +} + +func TestParseCrontabCommentsAndEmptyLines(t *testing.T) { + input := "# backup notturno\n\n# altro commento descrittivo\n0 3 * * * /bin/x\n\n" + res := ParseCrontab(input) + if res.CommentsCount != 2 { + t.Errorf("comments = %d, want 2", res.CommentsCount) + } + if len(res.Jobs) != 1 { + t.Errorf("jobs = %d, want 1", len(res.Jobs)) + } +} + +func TestParseCrontabDisabledJob(t *testing.T) { + input := "#0 4 * * * /usr/local/bin/disabled.sh\n# 30 2 * * 0 /bin/weekly.sh\n#@daily /bin/disabled-macro\n# solo un commento normale\n" + res := ParseCrontab(input) + if res.DisabledJobsCount != 3 { + t.Errorf("disabled jobs = %d, want 3", res.DisabledJobsCount) + } + if res.CommentsCount != 1 { + t.Errorf("comments = %d, want 1", res.CommentsCount) + } + for _, j := range res.Jobs { + if j.Enabled { + t.Errorf("job line %d should be disabled", j.LineNumber) + } + } +} + +func TestParseCrontabProseCommentNotDisabledJob(t *testing.T) { + // A prose comment starting with a number must NOT be misread as a + // disabled job ("5 minuti dopo ogni ora" is not a schedule). + res := ParseCrontab("# 5 minuti dopo ogni ora parte il sync\n") + if res.DisabledJobsCount != 0 { + t.Errorf("disabled = %d, want 0 (prose comment)", res.DisabledJobsCount) + } + if res.CommentsCount != 1 { + t.Errorf("comments = %d, want 1", res.CommentsCount) + } +} + +func TestParseCrontabEnvVars(t *testing.T) { + input := "MAILTO=admin@example.com\nPATH=/usr/local/bin:/usr/bin\nSHELL=/bin/bash\n0 1 * * * /bin/x\n" + res := ParseCrontab(input) + if len(res.Environment) != 3 { + t.Fatalf("env = %d, want 3", len(res.Environment)) + } + if res.Environment[0].Name != "MAILTO" || res.Environment[0].ValueRedacted != "admin@example.com" { + t.Errorf("MAILTO = %+v", res.Environment[0]) + } + if res.Environment[1].Name != "PATH" { + t.Errorf("PATH name = %q", res.Environment[1].Name) + } +} + +func TestParseCrontabSensitiveEnvRedacted(t *testing.T) { + input := "API_KEY=supersecretvalue\nDB_PASSWORD=hunter2\nMAILTO=x@y.z\n" + res := ParseCrontab(input) + if len(res.Environment) != 3 { + t.Fatalf("env = %d, want 3", len(res.Environment)) + } + for _, e := range res.Environment { + if e.Name == "API_KEY" || e.Name == "DB_PASSWORD" { + if strings.Contains(e.ValueRedacted, "supersecretvalue") || strings.Contains(e.ValueRedacted, "hunter2") { + t.Errorf("env %s leaked value: %q", e.Name, e.ValueRedacted) + } + } + if e.Name == "MAILTO" && e.ValueRedacted != "x@y.z" { + t.Errorf("MAILTO should not be redacted: %q", e.ValueRedacted) + } + } +} + +func TestParseCrontabInvalidLine(t *testing.T) { + res := ParseCrontab("this is not a valid cron line at all ???\n0 1 * * * /bin/ok\n") + if len(res.Jobs) != 1 { + t.Errorf("jobs = %d, want 1 (invalid line must not become a job)", len(res.Jobs)) + } + if len(res.Warnings) == 0 { + t.Fatal("expected warning for unparsable line") + } + w := res.Warnings[0] + if !strings.Contains(w, "sha256:") { + t.Errorf("warning must reference the line by hash, got: %q", w) + } + if strings.Contains(w, "???") { + t.Errorf("warning must not contain the raw line content: %q", w) + } +} + +func TestParseCrontabComplexCommands(t *testing.T) { + input := `0 2 * * * /usr/bin/mysqldump db | gzip > "/home/u/backups/db $(date +\%F).sql.gz" 2>&1 +30 4 * * * cd /home/u && ./run.sh --flag 'quoted arg' >> /dev/null +` + res := ParseCrontab(input) + if len(res.Jobs) != 2 { + t.Fatalf("jobs = %d, want 2", len(res.Jobs)) + } + if !strings.Contains(res.Jobs[0].CommandRedacted, "gzip") { + t.Errorf("pipe command mangled: %q", res.Jobs[0].CommandRedacted) + } + if !strings.Contains(res.Jobs[1].CommandRedacted, "'quoted arg'") { + t.Errorf("quoted arg mangled: %q", res.Jobs[1].CommandRedacted) + } +} + +func TestParseCrontabHashesPresent(t *testing.T) { + res := ParseCrontab("0 3 * * * /bin/backup --password=secret123\n") + if len(res.Jobs) != 1 { + t.Fatalf("jobs = %d", len(res.Jobs)) + } + j := res.Jobs[0] + if !strings.HasPrefix(j.CommandSHA256, "sha256:") || len(j.CommandSHA256) != len("sha256:")+64 { + t.Errorf("CommandSHA256 malformed: %q", j.CommandSHA256) + } + if !strings.HasPrefix(j.RawLineSHA256, "sha256:") { + t.Errorf("RawLineSHA256 malformed: %q", j.RawLineSHA256) + } + // Hashes are computed on the REDACTED command: hashing the raw text + // would hand out an offline brute-force oracle for the masked secret + // (the visible structure + a low-entropy password = dictionary check). + // Two jobs differing only in their secret therefore hash identically. + res2 := ParseCrontab("0 3 * * * /bin/backup --password=different456\n") + if res2.Jobs[0].CommandSHA256 != j.CommandSHA256 { + t.Error("CommandSHA256 must be computed on the redacted command (no raw-hash oracle)") + } + // And a genuinely different command still hashes differently. + res3 := ParseCrontab("0 3 * * * /bin/other-tool --password=x\n") + if res3.Jobs[0].CommandSHA256 == j.CommandSHA256 { + t.Error("different commands must produce different hashes") + } +} + +func TestParseCrontabEmptyInput(t *testing.T) { + res := ParseCrontab("") + if len(res.Jobs) != 0 || len(res.Environment) != 0 { + t.Errorf("empty crontab must yield no jobs/env: %+v", res) + } +} + +// --------------------------------------------------------------------------- +// Redaction +// --------------------------------------------------------------------------- + +func TestRedactCronCommand(t *testing.T) { + tests := []struct { + name string + in string + mustHide []string + mustKeep []string + }{ + { + name: "url with token param", + in: "curl https://api.example.com/hook?token=abc123secret&x=1", + mustHide: []string{"abc123secret"}, + mustKeep: []string{"api.example.com"}, + }, + { + name: "password flag", + in: "/bin/backup --password=hunter2 --dest=/backups", + mustHide: []string{"hunter2"}, + mustKeep: []string{"/bin/backup", "--dest=/backups"}, + }, + { + name: "api_key", + in: "wget 'https://x.y/cron.php?api_key=AKIA999888'", + mustHide: []string{"AKIA999888"}, + mustKeep: []string{"cron.php"}, + }, + { + // Fixture split by concatenation so secret scanners don't + // flag the FAKE test token as a real leaked credential. + name: "bearer header", + in: `curl -H "Authorization: ` + `Bearer FAKEJWT.testonly.notreal" https://x.y/`, + mustHide: []string{"FAKEJWT.testonly.notreal"}, + mustKeep: []string{"curl", "https://x.y/"}, + }, + { + name: "basic auth header", + in: `curl -H "Authorization: ` + `Basic RkFLRTp0ZXN0b25seQ==" https://x.y/ping`, + mustHide: []string{"RkFLRTp0ZXN0b25seQ=="}, + mustKeep: []string{"curl", "https://x.y/ping"}, + }, + { + name: "url credentials", + in: "rsync backup ftp://deploy:s3cr3tpw@files.example.com/dir", + mustHide: []string{"s3cr3tpw"}, + mustKeep: []string{"files.example.com"}, + }, + { + name: "sensitive env assignment in command", + in: "MYSQL_PWD=topsecret mysqldump mydb", + mustHide: []string{"topsecret"}, + mustKeep: []string{"mysqldump", "mydb"}, + }, + { + name: "clean command untouched", + in: "/usr/bin/php /home/user/artisan schedule:run >> /dev/null 2>&1", + mustHide: nil, + mustKeep: []string{"/usr/bin/php", "/home/user/artisan", "schedule:run", ">> /dev/null 2>&1"}, + }, + { + name: "mysqldump concatenated -p flag", + in: "mysqldump -u root -pMySecretPass123 mydb > /home/u/backup.sql", + mustHide: []string{"MySecretPass123"}, + mustKeep: []string{"mysqldump", "-u root", "mydb", "> /home/u/backup.sql"}, + }, + { + name: "space-separated --password flag", + in: "mysqldump --password MySecretPass456 --databases mydb", + mustHide: []string{"MySecretPass456"}, + mustKeep: []string{"mysqldump", "--databases", "mydb"}, + }, + { + name: "curl --user with credentials", + in: "curl --user " + "admin:S3cretPw https://x.y/status", + mustHide: []string{"S3cretPw"}, + mustKeep: []string{"curl", "https://x.y/status"}, + }, + { + name: "single-token URL credential (github PAT style)", + in: "git pull https://ghp_FAKEtoken0123456789@github.com/org/repo.git", + mustHide: []string{"ghp_FAKEtoken0123456789"}, + mustKeep: []string{"git pull", "github.com/org/repo.git"}, + }, + { + name: "at-sign inside url password", + in: "rsync backup ftp://deploy:sec@ret@files.example.com/dir", + mustHide: []string{"sec@ret", "ret@files"}, + mustKeep: []string{"files.example.com/dir"}, + }, + { + name: "email address survives (no scheme, not a credential)", + in: "echo done | mail -s report admin@example.com", + mustHide: nil, + mustKeep: []string{"admin@example.com", "mail -s report"}, + }, + { + name: "ssh-keygen space arg survives", + in: "ssh-keygen -f /home/u/.ssh/id_test -N ''", + mustHide: nil, + mustKeep: []string{"ssh-keygen", "-f /home/u/.ssh/id_test"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RedactCronCommand(tt.in) + for _, h := range tt.mustHide { + if strings.Contains(got, h) { + t.Errorf("secret %q leaked in: %q", h, got) + } + } + for _, k := range tt.mustKeep { + if !strings.Contains(got, k) { + t.Errorf("legit part %q lost in: %q", k, got) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// Fetch: marker protocol and error classification +// --------------------------------------------------------------------------- + +func TestFetchCrontabSuccess(t *testing.T) { + out := []byte("0 3 * * * /bin/backup\n@daily /bin/daily\n__CRONTAB_RC:0__\n") + r := &fakeRunner{out: out} + res, err := FetchCrontab(t.Context(), r) + if err != nil { + t.Fatalf("FetchCrontab: %v", err) + } + if len(res.Jobs) != 2 { + t.Errorf("jobs = %d, want 2", len(res.Jobs)) + } + if !strings.Contains(r.script, "crontab -l") { + t.Errorf("script must run crontab -l, got: %q", r.script) + } +} + +func TestFetchCrontabNoCrontabForUser(t *testing.T) { + out := []byte("no crontab for someuser\n__CRONTAB_RC:1__\n") + r := &fakeRunner{out: out} + res, err := FetchCrontab(t.Context(), r) + if err != nil { + t.Fatalf("'no crontab' must not be an error: %v", err) + } + if len(res.Jobs) != 0 { + t.Errorf("jobs = %d, want 0", len(res.Jobs)) + } + if len(res.Warnings) == 0 { + t.Error("expected a light warning for empty crontab") + } +} + +func TestFetchCrontabPermissionError(t *testing.T) { + out := []byte("crontab: you are not allowed to use this program\n__CRONTAB_RC:1__\n") + r := &fakeRunner{out: out} + _, err := FetchCrontab(t.Context(), r) + if err == nil { + t.Fatal("a real crontab error must surface as error") + } +} + +func TestFetchCrontabSSHError(t *testing.T) { + r := &fakeRunner{err: errors.New("ssh: connection lost")} + _, err := FetchCrontab(t.Context(), r) + if err == nil { + t.Fatal("SSH error must propagate") + } +} + +func TestFetchCrontabMissingMarker(t *testing.T) { + r := &fakeRunner{out: []byte("garbage output without marker")} + _, err := FetchCrontab(t.Context(), r) + if err == nil { + t.Fatal("missing RC marker must be an error") + } +} + +func TestFetchCrontabMarkerSpoofInContent(t *testing.T) { + // A job that prints the marker text must not hijack RC parsing: only + // the final standalone marker line counts. + out := []byte("0 1 * * * echo __CRONTAB_RC:9__ done\n__CRONTAB_RC:0__\n") + r := &fakeRunner{out: out} + res, err := FetchCrontab(t.Context(), r) + if err != nil { + t.Fatalf("FetchCrontab: %v", err) + } + if len(res.Jobs) != 1 { + t.Fatalf("jobs = %d, want 1", len(res.Jobs)) + } + if !strings.Contains(res.Jobs[0].CommandRedacted, "__CRONTAB_RC:9__") { + t.Errorf("spoofed marker text must stay part of the command: %q", res.Jobs[0].CommandRedacted) + } +} + +// Cron redaction must mask a "secure=" auth token, not just "token=": real +// PrestaShop cron jobs authenticate with secure=, which was leaking +// verbatim into the inventory and diff. +func TestRedactCronSecureToken(t *testing.T) { + in := "wget -O- https://shop.example.it/modules/ets_seo/cronjob.php secure=SEEKRETVALUE > /dev/null" + got := RedactCronCommand(in) + if strings.Contains(got, "SEEKRETVALUE") { + t.Errorf("secure= token leaked: %q", got) + } + if !strings.Contains(got, "cronjob.php") { + t.Errorf("legit command mangled: %q", got) + } + // The space-separated form (secure=X as a bare arg) and the query-string + // form (?...&secure=X) must both be caught. + q := "curl 'https://x.y/cron?token=aa&secure=SEEKRETVALUE&id=1'" + if got := RedactCronCommand(q); strings.Contains(got, "SEEKRETVALUE") { + t.Errorf("query-string secure= leaked: %q", got) + } +} + +// After unification cron reads the shared superset, which adds cookie/session +// (formerly cron-blind) on top of its own pwd/secure. +func TestRedactCronCommandCatchesCookieSession(t *testing.T) { + for _, tc := range []struct{ in, secret string }{ + {"curl 'https://x.y/cron?session=SEEKRET1&id=1'", "SEEKRET1"}, + {"curl --cookie=SEEKRET2 https://x.y/hook", "SEEKRET2"}, + } { + if got := RedactCronCommand(tc.in); strings.Contains(got, tc.secret) { + t.Errorf("cron leaked %q (cookie/session now in shared superset): %q", tc.secret, got) + } + } +} + +// Lock the one-source-of-truth wiring: cron must not rebuild the list. +func TestCronFragmentsSourcedFromShared(t *testing.T) { + if !reflect.DeepEqual(cronSensitiveNameFragments, redact.Fragments()) { + t.Error("cronSensitiveNameFragments diverged from redact.Fragments()") + } +} diff --git a/internal/cpanel/debug.go b/internal/cpanel/debug.go index d781d539..3af56221 100644 --- a/internal/cpanel/debug.go +++ b/internal/cpanel/debug.go @@ -4,11 +4,10 @@ package cpanel import ( - "bytes" - "encoding/json" - "fmt" "os" "strings" + + "github.com/tis24dev/cPanel_self-migration/internal/redact" ) // Raw UAPI response debugging. @@ -51,102 +50,15 @@ func rawDebugFromEnv() bool { } } -// sensitiveKeySubstrings drive redaction: a JSON object key whose lower-cased, -// space-trimmed form CONTAINS any of these is treated as a secret and its -// non-empty value is replaced before logging. Substring (not exact) matching is -// deliberate so variants like access_token, auth_token, client_secret, apikey, -// privatekey, x-csrf-token, … are all caught. Over-redaction is harmless here; a -// leaked credential is not. None of the fields we actually need to SEE (name, -// expires_at, expires, expiry, create_time, status) contain any of these, so the -// diagnostic value is preserved. -var sensitiveKeySubstrings = []string{ - "token", "secret", "pass", "key", "auth", "cred", "cookie", "session", "bearer", -} - -const redactedPlaceholder = "" - -// isSensitiveKey reports whether a JSON object key names a value to redact. -func isSensitiveKey(k string) bool { - k = strings.ToLower(strings.TrimSpace(k)) - for _, sub := range sensitiveKeySubstrings { - if strings.Contains(k, sub) { - return true - } - } - return false -} +// redactedPlaceholder aliases the shared redact.Placeholder so in-package +// callers and debug_test.go keep referring to the same masked value. +const redactedPlaceholder = redact.Placeholder -// redactJSONForDebug parses out as JSON and returns a re-serialized copy in -// which the value of every sensitive key (see isSensitiveKey), at any depth, is -// replaced with redactedPlaceholder. The structure is preserved so a response -// SHAPE can be inspected (e.g. "is expires_at present and numeric?") without -// exposing secrets. -// -// LIMITATION: redaction is key-based. A secret echoed inside the VALUE of a -// non-secret field (e.g. a free-text "message" that quotes the token) is NOT -// detected. The responses this tool reads keep secrets in their own keyed fields -// (create_full_access's token is data.token), so this is safe in practice, but do -// not enable the facility against a host known to inline secrets into messages. -// -// It never returns the raw bytes: if out is not valid JSON, or cannot be -// re-marshaled after redaction, it returns a short, value-free placeholder. This -// guarantees a secret cannot leak through this path even on malformed input. -// An empty or null sensitive value is left untouched, because "" / null carries -// no secret and the empty/absent state is itself diagnostically useful. +// redactJSONForDebug delegates to redact.RedactJSON: it re-serializes out with +// every sensitive key's value masked at any depth, shape-preserving, and +// withholds a value-free placeholder on non-JSON / bare top-level scalar / +// re-marshal failure. LIMITATION (unchanged): redaction is key-based; a secret +// inlined into a non-secret field's value is not detected. func redactJSONForDebug(out []byte) string { - var v any - if err := json.Unmarshal(out, &v); err != nil { - return fmt.Sprintf("<%d bytes, not valid JSON; withheld>", len(out)) - } - // A bare top-level JSON scalar (string/number/bool) has no key to match on and - // could itself be a secret. The real UAPI path always wraps data in an - // envelope object, so anything else is unexpected — withhold it rather than - // echo it. (redactInPlace only descends into objects/arrays.) - if _, isObj := v.(map[string]any); !isObj { - if _, isArr := v.([]any); !isArr { - return fmt.Sprintf("<%d bytes, top-level JSON scalar; withheld>", len(out)) - } - } - redactInPlace(v) - // Encode with HTML escaping off so the body stays human-readable in the log - // (otherwise the angle brackets in the placeholder, and any '<'/'>'/'&' in the - // data, come out as \uXXXX). Encode appends a newline; trim it. - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - if err := enc.Encode(v); err != nil { - return fmt.Sprintf("<%d bytes; withheld (re-marshal failed)>", len(out)) - } - return strings.TrimRight(buf.String(), "\n") -} - -// redactInPlace walks an unmarshaled JSON value and redacts sensitive object -// values in place. -func redactInPlace(v any) { - switch t := v.(type) { - case map[string]any: - for k, child := range t { - if isSensitiveKey(k) && !isEmptyJSONValue(child) { - t[k] = redactedPlaceholder - continue - } - redactInPlace(child) - } - case []any: - for _, child := range t { - redactInPlace(child) - } - } -} - -// isEmptyJSONValue reports whether a decoded JSON value carries no secret to -// hide: a JSON null, or an empty string. -func isEmptyJSONValue(v any) bool { - if v == nil { - return true - } - if s, ok := v.(string); ok { - return s == "" - } - return false + return redact.RedactJSON(out) } diff --git a/internal/cpanel/dns_apply.go b/internal/cpanel/dns_apply.go new file mode 100644 index 00000000..ede80f06 --- /dev/null +++ b/internal/cpanel/dns_apply.go @@ -0,0 +1,170 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +// DNS write primitives (PR 6D), the ONLY DNS writer. Called exclusively +// by the `dns apply` subcommand against the DESTINATION host. The sole +// write API is DNS::mass_edit_zone with serial guard (optimistic locking). +// Byte-verified in PR6D_PRE_CAPTURES.md. + +// MassEditAddRecord is one record to add via mass_edit_zone. Fields +// match the cPanel API: dname (relative for sub-domains, the FQDN zone name +// with trailing dot for the apex, mass_edit_zone REJECTS "@"; see +// dnsCanonToRelative), ttl, record_type, data (array of strings, e.g. TXT +// segments). +type MassEditAddRecord struct { + DName string `json:"dname"` + TTL int `json:"ttl"` + RecordType string `json:"record_type"` + Data []string `json:"data"` +} + +// MassEditResult is the response from a successful mass_edit_zone call. +type MassEditResult struct { + NewSerial string `json:"new_serial"` +} + +// MassEditZoneAdd adds records to a DNS zone via DNS::mass_edit_zone +// with serial guard (6D-pre fact 1: add-0=, add-1=, ... indexed format). +// The serial must be from a FRESH parse_zone, a stale serial causes a +// fail-safe refusal (6D-pre fact 3). +func MassEditZoneAdd(ctx context.Context, c Runner, zone, serial string, records []MassEditAddRecord) (MassEditResult, error) { + args := map[string]string{ + "zone": zone, + "serial": serial, + } + for i, r := range records { + b, err := json.Marshal(r) + if err != nil { + return MassEditResult{}, fmt.Errorf("marshal add record %d: %w", i, err) + } + args[fmt.Sprintf("add-%d", i)] = string(b) + } + data, err := RunUAPI[MassEditResult](ctx, c, "DNS", "mass_edit_zone", args) + if err != nil { + return MassEditResult{}, err + } + logx.Debug("MassEditZoneAdd(%s, serial=%s): %d record(s) added, new_serial=%s", + zone, serial, len(records), data.NewSerial) + return data, nil +} + +// MassEditZoneRemove removes records by line index via DNS::mass_edit_zone +// with serial guard (6D-pre: remove-0=, remove-1=, ... indexed format). +// This is the ROLLBACK primitive: the only DNS removes the tool ever +// emits are the inverses of its own applied adds. +func MassEditZoneRemove(ctx context.Context, c Runner, zone, serial string, lineIndexes []int) (MassEditResult, error) { + args := map[string]string{ + "zone": zone, + "serial": serial, + } + for i, idx := range lineIndexes { + args[fmt.Sprintf("remove-%d", i)] = strconv.Itoa(idx) + } + data, err := RunUAPI[MassEditResult](ctx, c, "DNS", "mass_edit_zone", args) + if err != nil { + return MassEditResult{}, err + } + logx.Debug("MassEditZoneRemove(%s, serial=%s): %d line(s) removed, new_serial=%s", + zone, serial, len(lineIndexes), data.NewSerial) + return data, nil +} + +// FetchDNSZoneRaw returns the raw parse_zone UAPI response bytes +// alongside the normalized records, for the pre-write backup (the +// backup archives the verbatim server state). +func FetchDNSZoneRaw(ctx context.Context, c Runner, zone string) ([]DNSRecord, []byte, error) { + data, raw, err := RunUAPIRaw[[]uapiDNSRawRecord](ctx, c, "DNS", "parse_zone", + map[string]string{"zone": zone}) + if err != nil { + return nil, nil, err + } + records, warns := normalizeUAPIRecords(data) + for _, w := range warns { + logx.Debug("FetchDNSZoneRaw(%s): %s", zone, w) + } + return records, raw, nil +} + +// ExtractSOASerial finds the SOA serial from a parse_zone response. +// The serial is base64-encoded in data_b64[2] of the SOA record +// (6D-pre fact 4). +func ExtractSOASerial(raw []byte) (string, error) { + type rawRec struct { + RecordType string `json:"record_type"` + DataB64 []string `json:"data_b64"` + } + type resp struct { + Result struct { + Data []rawRec `json:"data"` + } `json:"result"` + } + var r resp + if err := json.Unmarshal(raw, &r); err != nil { + return "", fmt.Errorf("parse zone response: %w", err) + } + for _, rec := range r.Result.Data { + if rec.RecordType == "SOA" && len(rec.DataB64) >= 3 { + decoded, err := base64.StdEncoding.DecodeString(rec.DataB64[2]) + if err != nil { + return "", fmt.Errorf("decode SOA serial base64 %q: %w", rec.DataB64[2], err) + } + serial := strings.TrimSpace(string(decoded)) + if serial == "" { + return "", fmt.Errorf("SOA serial is empty after decode") + } + return serial, nil + } + } + return "", fmt.Errorf("no SOA record found in zone response") +} + +// MassEditZoneBatch combines remove and add operations in a SINGLE +// mass_edit_zone call (v2 replace design: removes processed first, +// then adds, in one zone-file write). When removeLines is empty it is +// equivalent to MassEditZoneAdd; when addRecords is empty it is +// equivalent to MassEditZoneRemove. +func MassEditZoneBatch(ctx context.Context, c Runner, zone, serial string, removeLines []int, addRecords []MassEditAddRecord) (MassEditResult, error) { + args := map[string]string{ + "zone": zone, + "serial": serial, + } + for i, idx := range removeLines { + args[fmt.Sprintf("remove-%d", i)] = strconv.Itoa(idx) + } + for i, r := range addRecords { + b, err := json.Marshal(r) + if err != nil { + return MassEditResult{}, fmt.Errorf("marshal add record %d: %w", i, err) + } + args[fmt.Sprintf("add-%d", i)] = string(b) + } + data, err := RunUAPI[MassEditResult](ctx, c, "DNS", "mass_edit_zone", args) + if err != nil { + return MassEditResult{}, err + } + logx.Debug("MassEditZoneBatch(%s, serial=%s): %d remove(s) + %d add(s), new_serial=%s", + zone, serial, len(removeLines), len(addRecords), data.NewSerial) + return data, nil +} + +// IsStaleSerialError reports whether a mass_edit_zone error is the +// stale-serial refusal (6D-pre fact 3). +func IsStaleSerialError(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "does not match the DNS zone") +} diff --git a/internal/cpanel/dns_safety_test.go b/internal/cpanel/dns_safety_test.go new file mode 100644 index 00000000..09d6f40b --- /dev/null +++ b/internal/cpanel/dns_safety_test.go @@ -0,0 +1,289 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "fmt" + "go/ast" + "go/parser" + goscanner "go/scanner" + "go/token" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +// dnsWriteForbidden are the DNS write primitives no source may mention +// outside the allowlisted writer files: UAPI mass_edit_zone / +// swap_ip_in_zones, the API2 ZoneEdit record writers, and raw zone-file +// paths. PR 6D consciously adds the first per-file allowlist. +var dnsWriteForbidden = []string{ + "mass_edit_zone", + "swap_ip_in_zones", + "/var/named", + "add_zone_record", + "edit_zone_record", + "remove_zone_record", +} + +// dnsWriteAllowlist names the ONLY files that may reference the DNS +// write verbs, the writer primitives file. When the `dns apply` +// command file is created, it must be added here in the same PR. +// Amending this list is a conscious, reviewed act. +var dnsWriteAllowlist = map[string]bool{ + "internal/cpanel/dns_apply.go": true, + "cmd/cpanel-self-migration/dns_apply_cmd.go": true, +} + +func TestNoDNSWriteFunctions(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatalf("glob: %v", err) + } + + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + if dnsWriteAllowlist["internal/cpanel/"+f] { + continue + } + b, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + src := string(b) + for _, pattern := range dnsWriteForbidden { + if strings.Contains(src, pattern) { + t.Errorf("%s contains forbidden DNS write pattern %q (allowlist: dns_apply.go only)", f, pattern) + } + } + } +} + +// TestNoDNSWritePatternsModuleWide extends the scan to the whole module +// (cron_safety_test.go pattern): the dns-plan and dns verify sources live +// in internal/accountinventory and cmd/, outside this package's glob. +// Required by the PR 6A design ("dns-plan and dns verify sources contain +// no write calls"). +// +// Comments are exempt: design references legitimately NAME the write API +// (dnsplan.go documents the mass-edit shape 6D will target). The guarded +// property is that no code can CALL it, and a call requires the name in a +// string literal (RunUAPI/cpapi2 argv) or an identifier, so the scan +// tokenizes each file and checks only those tokens. +// +// Known limit: a name built at runtime ("mass_"+"edit_zone") defeats a +// lexical scan by construction. TestDNSAPICallsUseLiteralNames below +// closes that hole for the cPanel API entry points by REQUIRING literal +// module/function arguments, the concatenation itself becomes a failure. +func TestNoDNSWritePatternsModuleWide(t *testing.T) { + root := "../.." // module root from internal/cpanel + rootAbs, err := filepath.Abs(root) + if err != nil { + t.Fatal(err) + } + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if d.Name() == ".git" || d.Name() == "vendor" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + abs, err := filepath.Abs(path) + if err != nil { + return err + } + rel, err := filepath.Rel(rootAbs, abs) + if err != nil { + return err + } + if dnsWriteAllowlist[filepath.ToSlash(rel)] { + return nil + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + fset := token.NewFileSet() + f := fset.AddFile(path, fset.Base(), len(b)) + var s goscanner.Scanner + s.Init(f, b, nil, 0) + for { + pos, tok, lit := s.Scan() + if tok == token.EOF { + break + } + if tok != token.STRING && tok != token.CHAR && tok != token.IDENT { + continue + } + for _, pattern := range dnsWriteForbidden { + if strings.Contains(lit, pattern) { + t.Errorf("%s: %s token %q contains forbidden DNS write pattern %q (allowlist: dns_apply.go only)", + fset.Position(pos), tok, lit, pattern) + } + } + } + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } +} + +// TestDNSAPICallsUseLiteralNames is the structural companion of the +// lexical scans (go-reviewer finding on PR 6C): every RunUAPI/RunAPI2 +// call in the module must pass its module and function names as PLAIN +// STRING LITERALS, so the forbidden-pattern scan above is guaranteed to +// see them. A dynamically built name (concatenation, Sprintf, variable) +// fails here regardless of its value, the bypass itself is the error. +// +// Residual limit, accepted: a writer that avoids RunUAPI/RunAPI2 entirely +// (a hand-built `uapi ...` script through Runner.RunScript) is not caught +// by this test; new RunScript call sites remain a human-review surface. +func TestDNSAPICallsUseLiteralNames(t *testing.T) { + root := "../.." // module root from internal/cpanel + fset := token.NewFileSet() + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if d.Name() == ".git" || d.Name() == "vendor" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + name := calleeBaseName(call.Fun) + // RunUAPIRaw (PR 2B-1) is the same executor returning also the + // raw bytes, same literal-name contract. + if name != "RunUAPI" && name != "RunAPI2" && name != "RunUAPIRaw" { + return true + } + if len(call.Args) < 4 { + return true // not the (ctx, runner, module, fn, args) shape + } + for i, arg := range call.Args[2:4] { + if lit, ok := arg.(*ast.BasicLit); !ok || lit.Kind != token.STRING { + t.Errorf("%s: %s call builds its %s name dynamically, the DNS write scan cannot vet it, use a string literal", + fset.Position(call.Pos()), name, []string{"module", "function"}[i]) + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } +} + +// dnsWriteWrapperFns are the exported Go wrappers around the DNS write +// primitives. The string-pattern scans above are case-sensitive on +// "mass_edit_zone" and never see these CamelCase identifiers, so a call +// to a wrapper from a non-allowlisted file would slip past the guard. +// This AST check closes that gap: the wrappers may be CALLED only from +// allowlisted writer files. +var dnsWriteWrapperFns = map[string]bool{ + "MassEditZoneAdd": true, + "MassEditZoneRemove": true, + "MassEditZoneBatch": true, +} + +// TestNoDNSWriteWrapperCallsOutsideAllowlist fails if any non-allowlisted +// production file calls a DNS write wrapper. Complements the string scans +// (which the CamelCase wrapper identifiers evade) so the write path stays +// funneled through the reviewed allowlist. +func TestNoDNSWriteWrapperCallsOutsideAllowlist(t *testing.T) { + root := "../.." // module root from internal/cpanel + fset := token.NewFileSet() + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if d.Name() == ".git" || d.Name() == "vendor" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if dnsWriteAllowlist[filepath.ToSlash(rel)] { + return nil + } + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if name := calleeBaseName(call.Fun); dnsWriteWrapperFns[name] { + t.Errorf("%s: calls DNS write wrapper %s outside the allowlist; add the file to dnsWriteAllowlist only after review", + fset.Position(call.Pos()), name) + } + return true + }) + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } +} + +// TestDNSWriteAllowlistFilesExist pins the allowlist against silent rot: +// an allowlisted path that no longer exists means the writer moved and +// the scan is guarding the wrong file (mirror of email test). +func TestDNSWriteAllowlistFilesExist(t *testing.T) { + for rel := range dnsWriteAllowlist { + p := filepath.Join("../..", filepath.FromSlash(rel)) + if _, err := os.Stat(p); err != nil { + t.Errorf("allowlisted file %s does not exist: %v", rel, err) + } + } +} + +// calleeBaseName unwraps generic instantiation (RunUAPI[T]) and package +// selectors (cpanel.RunUAPI) down to the called identifier. +func calleeBaseName(e ast.Expr) string { + switch v := e.(type) { + case *ast.Ident: + return v.Name + case *ast.SelectorExpr: + return v.Sel.Name + case *ast.IndexExpr: + return calleeBaseName(v.X) + case *ast.IndexListExpr: + return calleeBaseName(v.X) + } + return "" +} diff --git a/internal/cpanel/dns_zones.go b/internal/cpanel/dns_zones.go new file mode 100644 index 00000000..018d7c67 --- /dev/null +++ b/internal/cpanel/dns_zones.go @@ -0,0 +1,274 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "sort" + "strings" + "unicode/utf8" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +// DNSRecord is a single normalized DNS record, produced from either UAPI +// DNS::parse_zone or API2 ZoneEdit::fetchzone_records. Type-specific fields +// are populated when applicable; unknown record types preserve their raw API +// response in the Raw field. +type DNSRecord struct { + Type string `json:"type"` + Name string `json:"name"` + TTL int `json:"ttl"` + Value string `json:"value"` + Priority int `json:"priority,omitempty"` + Exchange string `json:"exchange,omitempty"` + Address string `json:"address,omitempty"` + Target string `json:"target,omitempty"` + TxtData string `json:"txtdata,omitempty"` + Class string `json:"class,omitempty"` + Line int `json:"line,omitempty"` + Raw json.RawMessage `json:"raw,omitempty"` +} + +// --------------------------------------------------------------------------- +// UAPI DNS::parse_zone (cPanel >= v136) +// --------------------------------------------------------------------------- + +type uapiDNSRawRecord struct { + DNameB64 string `json:"dname_b64"` + RecordType string `json:"record_type"` + DataB64 []string `json:"data_b64"` + TTL flexInt64 `json:"ttl"` // number OR quoted string across cPanel builds + LineIndex int `json:"line_index"` + Type string `json:"type"` // "record", "control", "comment" +} + +func FetchDNSZoneUAPI(ctx context.Context, c Runner, zone string) ([]DNSRecord, error) { + data, err := RunUAPI[[]uapiDNSRawRecord](ctx, c, "DNS", "parse_zone", + map[string]string{"zone": zone}) + if err != nil { + return nil, err + } + records, warns := normalizeUAPIRecords(data) + for _, w := range warns { + logx.Debug("FetchDNSZoneUAPI(%s): %s", zone, w) + } + logx.Debug("FetchDNSZoneUAPI(%s): %d record(s)", zone, len(records)) + return records, nil +} + +func normalizeUAPIRecords(raw []uapiDNSRawRecord) ([]DNSRecord, []string) { + var records []DNSRecord + var warnings []string + for _, r := range raw { + if r.Type != "record" { + continue + } + dname, err := decodeB64Field(r.DNameB64) + if err != nil { + warnings = append(warnings, fmt.Sprintf("line %d: dname decode: %v", r.LineIndex, err)) + dname = r.DNameB64 + } + + var decodedData []string + for _, d := range r.DataB64 { + val, err := decodeB64Field(d) + if err != nil { + warnings = append(warnings, fmt.Sprintf("line %d: data decode: %v", r.LineIndex, err)) + val = d + } + decodedData = append(decodedData, val) + } + + rec := DNSRecord{ + Type: r.RecordType, + Name: dname, + TTL: int(r.TTL), + Line: r.LineIndex, + } + + switch r.RecordType { + case "A", "AAAA": + if len(decodedData) > 0 { + rec.Address = decodedData[0] + rec.Value = decodedData[0] + } + case "CNAME": + if len(decodedData) > 0 { + rec.Target = decodedData[0] + rec.Value = decodedData[0] + } + case "MX": + if len(decodedData) > 1 { + rec.Exchange = decodedData[1] + rec.Value = decodedData[1] + if p, err := parseInt(decodedData[0]); err == nil { + rec.Priority = p + } + } else if len(decodedData) > 0 { + rec.Exchange = decodedData[0] + rec.Value = decodedData[0] + } + case "TXT": + // Long TXT values (DKIM keys) arrive split into 255-char + // segments; RFC 1035 semantics concatenate them. + if len(decodedData) > 0 { + joined := strings.Join(decodedData, "") + rec.TxtData = joined + rec.Value = joined + } + case "NS": + if len(decodedData) > 0 { + rec.Target = decodedData[0] + rec.Value = decodedData[0] + } + default: + if len(decodedData) > 0 { + rec.Value = decodedData[0] + } + rawBytes, _ := json.Marshal(r) + rec.Raw = rawBytes + } + + records = append(records, rec) + } + sort.SliceStable(records, func(i, j int) bool { return records[i].Line < records[j].Line }) + return records, warnings +} + +func decodeB64Field(s string) (string, error) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "", fmt.Errorf("base64 decode: %w", err) + } + if utf8.Valid(b) { + return string(b), nil + } + return fmt.Sprintf("%x", b), nil +} + +func parseInt(s string) (int, error) { + var n int + for _, c := range s { + if c < '0' || c > '9' { + return 0, fmt.Errorf("not a number: %q", s) + } + n = n*10 + int(c-'0') + } + return n, nil +} + +// parseUAPIDNSZone is exposed for unit testing against fixture bytes. +func parseUAPIDNSZone(out []byte) ([]DNSRecord, error) { + data, err := parseUAPI[[]uapiDNSRawRecord]("DNS", "parse_zone", out) + if err != nil { + return nil, err + } + records, _ := normalizeUAPIRecords(data) + return records, nil +} + +// --------------------------------------------------------------------------- +// API2 ZoneEdit::fetchzone_records (legacy fallback) +// --------------------------------------------------------------------------- + +// api2DNSRawRecord is one entry of ZoneEdit::fetchzone_records. Verified +// against a live cPanel 110.0 (build 131): numeric fields arrive as EITHER +// bare numbers or quoted strings depending on the field and record type +// (e.g. the $TTL pseudo-record carries ttl:"14400" while an A record carries +// ttl:14400; MX preference and all SOA counters are quoted strings), so every +// numeric field uses flexInt64, a plain int would fail the WHOLE zone decode. +type api2DNSRawRecord struct { + Line flexInt64 `json:"line"` + Type string `json:"type"` + Name string `json:"name"` + TTL flexInt64 `json:"ttl"` + Class string `json:"class"` + Record string `json:"record"` + Address string `json:"address,omitempty"` + Cname string `json:"cname,omitempty"` + Exchange string `json:"exchange,omitempty"` + Preference flexInt64 `json:"preference,omitempty"` + TxtData string `json:"txtdata,omitempty"` + NSDName string `json:"nsdname,omitempty"` + MName string `json:"mname,omitempty"` + RName string `json:"rname,omitempty"` + Serial flexInt64 `json:"serial,omitempty"` + Refresh flexInt64 `json:"refresh,omitempty"` + Retry flexInt64 `json:"retry,omitempty"` + Expire flexInt64 `json:"expire,omitempty"` + Minimum flexInt64 `json:"minimum,omitempty"` + RawField string `json:"raw,omitempty"` +} + +func FetchDNSZoneAPI2(ctx context.Context, c Runner, domain string) ([]DNSRecord, error) { + data, err := RunAPI2[[]api2DNSRawRecord](ctx, c, "ZoneEdit", "fetchzone_records", + map[string]string{"domain": domain}) + if err != nil { + return nil, err + } + records := normalizeAPI2Records(data) + logx.Debug("FetchDNSZoneAPI2(%s): %d record(s)", domain, len(records)) + return records, nil +} + +func normalizeAPI2Records(raw []api2DNSRawRecord) []DNSRecord { + var records []DNSRecord + for _, r := range raw { + rec := DNSRecord{ + Type: r.Type, + Name: r.Name, + TTL: int(r.TTL), + Class: r.Class, + Line: int(r.Line), + } + + switch r.Type { + case "A", "AAAA": + rec.Address = r.Address + rec.Value = r.Address + case "CNAME": + rec.Target = r.Cname + rec.Value = r.Cname + case "MX": + rec.Exchange = r.Exchange + rec.Priority = int(r.Preference) + rec.Value = r.Exchange + case "TXT": + rec.TxtData = r.TxtData + rec.Value = r.TxtData + case "NS": + rec.Target = r.NSDName + rec.Value = r.NSDName + case "SOA": + rec.Value = fmt.Sprintf("%s %s %d", r.MName, r.RName, int64(r.Serial)) + rawBytes, _ := json.Marshal(r) + rec.Raw = rawBytes + case ":RAW": + rec.Value = r.RawField + rawBytes, _ := json.Marshal(r) + rec.Raw = rawBytes + default: + rec.Value = r.Record + rawBytes, _ := json.Marshal(r) + rec.Raw = rawBytes + } + + records = append(records, rec) + } + sort.SliceStable(records, func(i, j int) bool { return records[i].Line < records[j].Line }) + return records +} + +// parseAPI2DNSZone is exposed for unit testing against fixture bytes. +func parseAPI2DNSZone(out []byte) ([]DNSRecord, error) { + data, err := parseAPI2[[]api2DNSRawRecord]("ZoneEdit", "fetchzone_records", out) + if err != nil { + return nil, err + } + return normalizeAPI2Records(data), nil +} diff --git a/internal/cpanel/dns_zones_test.go b/internal/cpanel/dns_zones_test.go new file mode 100644 index 00000000..ecfea792 --- /dev/null +++ b/internal/cpanel/dns_zones_test.go @@ -0,0 +1,295 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "encoding/base64" + "encoding/json" + "testing" +) + +// --------------------------------------------------------------------------- +// UAPI DNS::parse_zone +// --------------------------------------------------------------------------- + +func TestParseDNSZoneUAPI(t *testing.T) { + data := fixture(t, "dns_parse_zone.json") + records, err := parseUAPIDNSZone(data) + if err != nil { + t.Fatalf("parseUAPIDNSZone: %v", err) + } + if len(records) == 0 { + t.Fatal("expected records, got none") + } + + want := map[string]bool{"A": false, "AAAA": false, "CNAME": false, "MX": false, "TXT": false, "NS": false} + for _, r := range records { + want[r.Type] = true + } + for typ, found := range want { + if !found { + t.Errorf("missing record type %s", typ) + } + } +} + +func TestUAPIDNSBase64Decode(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte("example.com.")) + got, err := decodeB64Field(encoded) + if err != nil { + t.Fatalf("decodeB64Field: %v", err) + } + if got != "example.com." { + t.Errorf("got %q, want %q", got, "example.com.") + } +} + +func TestUAPIDNSBase64NonUTF8(t *testing.T) { + raw := []byte{0xff, 0xfe, 0x00, 0x01} + encoded := base64.StdEncoding.EncodeToString(raw) + got, err := decodeB64Field(encoded) + if err != nil { + t.Fatalf("should not error on non-UTF8: %v", err) + } + if got == "" { + t.Error("should return non-empty string for non-UTF8 data") + } +} + +func TestUAPIDNSBase64InvalidEncoding(t *testing.T) { + _, err := decodeB64Field("!!!not-base64!!!") + if err == nil { + t.Error("expected error for invalid base64") + } +} + +func TestUAPIDNSTXTMultiSegmentJoined(t *testing.T) { + data := fixture(t, "dns_parse_zone.json") + records, err := parseUAPIDNSZone(data) + if err != nil { + t.Fatalf("parseUAPIDNSZone: %v", err) + } + // Real servers split long TXT (DKIM) into 255-char segments; RFC 1035 + // concatenates them. The parser must join all segments, not keep only + // the first (which would truncate DKIM keys). + found := false + for _, r := range records { + if r.Type == "TXT" && r.Name == "dkim._domainkey.example.com." { + found = true + want := "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AQEFAAOCAQ8AMIIBCgKCAQEA" + if r.TxtData != want { + t.Errorf("multi-segment TXT not joined:\ngot %q\nwant %q", r.TxtData, want) + } + if r.Value != want { + t.Errorf("multi-segment TXT Value not joined: %q", r.Value) + } + } + } + if !found { + t.Fatal("multi-segment TXT record not found in fixture") + } +} + +func TestUAPIDNSParseError(t *testing.T) { + data := []byte(`{"result":{"data":null,"errors":["The function \"parse_zone\" does not exist in module \"DNS\"."],"status":0}}`) + _, err := parseUAPIDNSZone(data) + if err == nil { + t.Fatal("expected error for UAPI failure") + } +} + +// --------------------------------------------------------------------------- +// API2 ZoneEdit::fetchzone_records +// --------------------------------------------------------------------------- + +func TestParseAPI2DNSZone(t *testing.T) { + data := fixture(t, "dns_fetchzone_records.json") + records, err := parseAPI2DNSZone(data) + if err != nil { + t.Fatalf("parseAPI2DNSZone: %v", err) + } + if len(records) == 0 { + t.Fatal("expected records, got none") + } + + want := map[string]bool{ + "A": false, "AAAA": false, "CNAME": false, + "MX": false, "TXT": false, "NS": false, "SOA": false, + } + for _, r := range records { + if _, ok := want[r.Type]; ok { + want[r.Type] = true + } + } + for typ, found := range want { + if !found { + t.Errorf("missing record type %s", typ) + } + } +} + +func TestAPI2DNSRecordFields(t *testing.T) { + data := fixture(t, "dns_fetchzone_records.json") + records, err := parseAPI2DNSZone(data) + if err != nil { + t.Fatalf("parseAPI2DNSZone: %v", err) + } + + // Field values mirror the live-server shape: cPanel api2 omits the + // trailing dot on exchange/cname/nsdname and quotes MX preference ("10"). + tests := []struct { + typ string + check func(DNSRecord) bool + desc string + }{ + {"A", func(r DNSRecord) bool { return r.Address == "192.168.1.1" }, "address"}, + {"AAAA", func(r DNSRecord) bool { return r.Address == "2001:db8::1" }, "IPv6 address"}, + {"CNAME", func(r DNSRecord) bool { return r.Target == "example.com" }, "cname target"}, + {"MX", func(r DNSRecord) bool { return r.Exchange == "mail.example.com" && r.Priority == 10 }, "exchange+priority from quoted string"}, + {"TXT", func(r DNSRecord) bool { return r.TxtData == "v=spf1 include:_spf.google.com ~all" }, "txtdata"}, + {"NS", func(r DNSRecord) bool { return r.Target == "ns1.example.com" }, "nsdname"}, + } + + for _, tt := range tests { + t.Run(tt.typ, func(t *testing.T) { + found := false + for _, r := range records { + if r.Type == tt.typ && tt.check(r) { + found = true + break + } + } + if !found { + t.Errorf("no %s record with expected %s", tt.typ, tt.desc) + } + }) + } +} + +func TestAPI2DNSRawRecordPreserved(t *testing.T) { + data := fixture(t, "dns_fetchzone_records.json") + records, err := parseAPI2DNSZone(data) + if err != nil { + t.Fatalf("parseAPI2DNSZone: %v", err) + } + + foundRaw := false + for _, r := range records { + if r.Raw != nil { + foundRaw = true + var m map[string]interface{} + if err := json.Unmarshal(r.Raw, &m); err != nil { + t.Errorf("raw field is not valid JSON: %v", err) + } + break + } + } + if !foundRaw { + t.Error("expected at least one record with raw metadata (:RAW type)") + } +} + +func TestAPI2DNSParseError(t *testing.T) { + data := []byte(`{"cpanelresult":{"data":[],"event":{"result":0},"error":"Zone not found"}}`) + _, err := parseAPI2DNSZone(data) + if err == nil { + t.Fatal("expected error for API2 failure") + } +} + +func TestAPI2DNSMalformedJSON(t *testing.T) { + _, err := parseAPI2DNSZone([]byte(`{{{not json`)) + if err == nil { + t.Fatal("expected error for malformed JSON") + } +} + +func TestAPI2DNSEventResultAsString(t *testing.T) { + data := []byte(`{"cpanelresult":{"data":[{"line":1,"type":"A","name":"x.com.","address":"1.2.3.4","ttl":14400,"class":"IN","record":"x"}],"event":{"result":"1"}}}`) + records, err := parseAPI2DNSZone(data) + if err != nil { + t.Fatalf("event.result as string '1' should succeed: %v", err) + } + if len(records) != 1 { + t.Errorf("expected 1 record, got %d", len(records)) + } +} + +func TestAPI2DNSEventResultStringFailure(t *testing.T) { + data := []byte(`{"cpanelresult":{"data":[],"event":{"result":"0"},"error":"Zone not found"}}`) + _, err := parseAPI2DNSZone(data) + if err == nil { + t.Fatal("event.result '0' should fail") + } +} + +// --------------------------------------------------------------------------- +// RunAPI2 infrastructure +// --------------------------------------------------------------------------- + +func TestAPI2ArgsScript(t *testing.T) { + script, env := api2ArgsScript("ZoneEdit", "fetchzone_records", map[string]string{"domain": "example.com"}) + if !findSubstringInTest(script, "cpapi2 --output=json") { + t.Errorf("script missing cpapi2 command: %s", script) + } + if !findSubstringInTest(script, "ZoneEdit") { + t.Errorf("script missing module: %s", script) + } + if !findSubstringInTest(script, "fetchzone_records") { + t.Errorf("script missing function: %s", script) + } + if len(env) == 0 { + t.Error("expected env vars for args") + } +} + +func findSubstringInTest(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// Normalization common +// --------------------------------------------------------------------------- + +func TestDNSRecordValueField(t *testing.T) { + data := fixture(t, "dns_fetchzone_records.json") + records, err := parseAPI2DNSZone(data) + if err != nil { + t.Fatalf("parseAPI2DNSZone: %v", err) + } + + for _, r := range records { + // Pseudo-records (:RAW comments, $TTL directive) and SOA carry + // their payload in Raw, not Value. + if r.Type == ":RAW" || r.Type == "SOA" || r.Type == "$TTL" { + continue + } + if r.Value == "" { + t.Errorf("record type %s has empty Value field", r.Type) + } + } +} + +func TestDNSRecordAllHaveTTLAndName(t *testing.T) { + data := fixture(t, "dns_fetchzone_records.json") + records, err := parseAPI2DNSZone(data) + if err != nil { + t.Fatalf("parseAPI2DNSZone: %v", err) + } + + for _, r := range records { + // :RAW comments and the $TTL directive have no owner name. + if r.Type == ":RAW" || r.Type == "$TTL" { + continue + } + if r.Name == "" { + t.Errorf("record type %s line %d has empty Name", r.Type, r.Line) + } + } +} diff --git a/internal/cpanel/email_accounts.go b/internal/cpanel/email_accounts.go new file mode 100644 index 00000000..a2cde48d --- /dev/null +++ b/internal/cpanel/email_accounts.go @@ -0,0 +1,31 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "sort" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +type EmailAccountEntry struct { + Email string `json:"email"` + Domain string `json:"domain"` + Login string `json:"login"` + // DiskUsedBytes binds "_diskused" (bytes), which the live server sends as + // a quoted string ("3779010736"). The previous binding to "diskusedquota" + // matched no real field, so every mailbox reported 0 disk used. + DiskUsedBytes flexInt64 `json:"_diskused"` +} + +func ListEmailAccounts(ctx context.Context, c Runner) ([]EmailAccountEntry, error) { + data, err := RunUAPI[[]EmailAccountEntry](ctx, c, "Email", "list_pops_with_disk", nil) + if err != nil { + return nil, err + } + sort.SliceStable(data, func(i, j int) bool { return data[i].Email < data[j].Email }) + logx.Debug("ListEmailAccounts: %d account(s)", len(data)) + return data, nil +} diff --git a/internal/cpanel/email_accounts_test.go b/internal/cpanel/email_accounts_test.go new file mode 100644 index 00000000..8b846227 --- /dev/null +++ b/internal/cpanel/email_accounts_test.go @@ -0,0 +1,50 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "testing" +) + +func TestParseListEmailAccounts(t *testing.T) { + data, err := parseUAPI[[]EmailAccountEntry]("Email", "list_pops_with_disk", fixture(t, "email_list_pops.json")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 3 { + t.Fatalf("got %d accounts, want 3", len(data)) + } + + want := []struct { + email string + domain string + disk int64 + }{ + {"info@main.example", "main.example", 2048}, + {"admin@main.example", "main.example", 51200}, + {"contact@addon.example", "addon.example", 0}, + } + for i, w := range want { + if data[i].Email != w.email { + t.Errorf("[%d] email = %q, want %q", i, data[i].Email, w.email) + } + if data[i].Domain != w.domain { + t.Errorf("[%d] domain = %q, want %q", i, data[i].Domain, w.domain) + } + if int64(data[i].DiskUsedBytes) != w.disk { + t.Errorf("[%d] disk = %d, want %d", i, int64(data[i].DiskUsedBytes), w.disk) + } + } +} + +func TestParseListEmailAccountsEmpty(t *testing.T) { + empty := []byte(`{"result":{"data":[],"errors":null,"messages":null,"status":1}}`) + data, err := parseUAPI[[]EmailAccountEntry]("Email", "list_pops_with_disk", empty) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 0 { + t.Errorf("got %d accounts, want 0", len(data)) + } +} diff --git a/internal/cpanel/email_apply.go b/internal/cpanel/email_apply.go new file mode 100644 index 00000000..da961686 --- /dev/null +++ b/internal/cpanel/email_apply.go @@ -0,0 +1,320 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "encoding/json" + "sort" + "strconv" + "strings" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +// Email-config write primitives (PR 2B-1), the FIRST config writers of +// the tool, byte-verified on the sacrificial destination account in +// PR2B_PRE_CAPTURES.md. They are called ONLY by the `email apply` +// subcommand, exclusively against the DESTINATION host; the module-wide +// TestNoEmailWritePatternsModuleWide scan allowlists exactly this file +// and the apply command file, and the structural +// TestDNSAPICallsUseLiteralNames guard pins the literal module/function +// names below. cPanel dedupes an exact-duplicate add_forwarder +// (2B-pre finding 2), so a racing re-run cannot create duplicates; the +// apply's unconditional per-op verify-after remains the belt-and-braces. + +// AddForwarder creates a single-address email forwarder: +// Email::add_forwarder domain= email= fwdopt=fwd fwdemail= +// (2B-pre finding 1). Multi-target/pipe/system forms are never written - +// the plan classifies them terminal manual. +func AddForwarder(ctx context.Context, c Runner, domain, email, fwdemail string) error { + _, err := RunUAPI[json.RawMessage](ctx, c, "Email", "add_forwarder", map[string]string{ + "domain": domain, + "email": email, + "fwdopt": "fwd", + "fwdemail": fwdemail, + }) + if err != nil { + return err + } + logx.Debug("AddForwarder(%s@%s -> %s): ok", email, domain, fwdemail) + return nil +} + +// DeleteForwarder removes one forwarder pair: +// Email::delete_forwarder address= forwarder= +// (2B-pre finding 3). This is the ROLLBACK primitive: the only deletes +// the tool ever emits are the inverses of its own applied creates. +func DeleteForwarder(ctx context.Context, c Runner, address, forwarder string) error { + _, err := RunUAPI[json.RawMessage](ctx, c, "Email", "delete_forwarder", map[string]string{ + "address": address, + "forwarder": forwarder, + }) + if err != nil { + return err + } + logx.Debug("DeleteForwarder(%s -> %s): ok", address, forwarder) + return nil +} + +// SetDefaultAddress sets a domain's default (catch-all) address: +// Email::set_default_address domain= fwdopt= [fwdemail=|failmsgs=]. +// The fwdopt is derived from the value's shape: `:fail:`/`:blackhole:` +// system forms (prefix-matched, the human-readable tail is +// locale-dependent) map to their own fwdopt, anything else goes verbatim +// via fwdopt=fwd. +// +// Byte-verified on the sacrificial dest: fwdopt=fwd with a real address +// (2B-pre finding 5) AND with a bare account username, the rollback +// restore shape, whose stored value round-trips identical to the +// fresh-account default (PR2B_1_SMOKE.md, go-review finding 1). NOT yet +// byte-verified: the fwdopt=fail/failmsgs and fwdopt=blackhole shapes +// (no such source exists in the current bench; the caller's +// verify-after re-list bounds a wrong write). Verification here means +// list round-trip, not delivery behavior. +func SetDefaultAddress(ctx context.Context, c Runner, domain, value string) error { + v := strings.TrimSpace(value) + args := map[string]string{"domain": domain} + switch { + case strings.HasPrefix(v, ":fail:"): + args["fwdopt"] = "fail" + if msg := strings.TrimSpace(strings.TrimPrefix(v, ":fail:")); msg != "" { + args["failmsgs"] = msg + } + case strings.HasPrefix(v, ":blackhole:"): + args["fwdopt"] = "blackhole" + default: + args["fwdopt"] = "fwd" + args["fwdemail"] = v + } + _, err := RunUAPI[json.RawMessage](ctx, c, "Email", "set_default_address", args) + if err != nil { + return err + } + logx.Debug("SetDefaultAddress(%s -> %s): ok", domain, v) + return nil +} + +// AutoresponderWrite is the content payload of AddAutoresponder, exactly +// the round-trippable field set of the byte-verified add_auto_responder +// call (2B-2-pre fact 1). +type AutoresponderWrite struct { + From string + Subject string + Body string + IsHTML int + Interval int + Start int64 + Stop int64 + Charset string +} + +// AddAutoresponder creates an autoresponder: +// Email::add_auto_responder email= domain= from= subject= +// body= is_html= interval= [charset=] [start= stop=] (2B-2-pre fact 1). +// start/stop are omitted when zero, the byte-verified call stores null +// for absent values, and passing 0 was never probed. WARNING: The call UPSERTS +// (fact 7): callers must have proven the address empty via a fresh +// re-list first, the apply guard refuses otherwise (never-overwrite). +func AddAutoresponder(ctx context.Context, c Runner, domain, email string, w AutoresponderWrite) error { + args := map[string]string{ + "domain": domain, + "email": email, + "from": w.From, + "subject": w.Subject, + "body": w.Body, + "is_html": strconv.Itoa(w.IsHTML), + "interval": strconv.Itoa(w.Interval), + "charset": w.Charset, + } + if w.Start != 0 { + args["start"] = strconv.FormatInt(w.Start, 10) + } + if w.Stop != 0 { + args["stop"] = strconv.FormatInt(w.Stop, 10) + } + _, err := RunUAPI[json.RawMessage](ctx, c, "Email", "add_auto_responder", args) + if err != nil { + return err + } + logx.Debug("AddAutoresponder(%s@%s): ok (%d body bytes)", email, domain, len(w.Body)) + return nil +} + +// DeleteAutoresponder removes one autoresponder: +// Email::delete_auto_responder email= (2B-2-pre fact 8). +// This is the ROLLBACK primitive for the tool's own applied autoresponder +// creates, safe precisely because the apply guard proved the address was +// empty before the write. +func DeleteAutoresponder(ctx context.Context, c Runner, email string) error { + _, err := RunUAPI[json.RawMessage](ctx, c, "Email", "delete_auto_responder", map[string]string{ + "email": email, + }) + if err != nil { + return err + } + logx.Debug("DeleteAutoresponder(%s): ok", email) + return nil +} + +// StoreFilter creates or overwrites an email filter: +// Email::store_filter filtername= match_type= part1= match1= val1= +// [action1= dest1=] [account=] (2B-3-pre facts 1/5/6). +// WARNING: store_filter UPSERTS: callers must have proven the filter absent +// or content-identical via a fresh re-list + get, the apply guard +// refuses otherwise (never-overwrite posture). +func StoreFilter(ctx context.Context, c Runner, filtername, account string, + rules []FilterRuleDecoded, actions []FilterActionDecoded) error { + args := map[string]string{ + "filtername": filtername, + "match_type": "is", + } + if account != "" { + args["account"] = account + } + for i, r := range rules { + idx := strconv.Itoa(i + 1) + args["part"+idx] = r.Part + args["match"+idx] = r.Match + args["val"+idx] = r.Val + } + for i, a := range actions { + idx := strconv.Itoa(i + 1) + args["action"+idx] = a.Action + if a.Dest != nil && *a.Dest != "" { + args["dest"+idx] = *a.Dest + } + } + _, err := RunUAPI[json.RawMessage](ctx, c, "Email", "store_filter", args) + if err != nil { + return err + } + logx.Debug("StoreFilter(%q, account=%q): ok", filtername, account) + return nil +} + +// DeleteFilter removes one email filter: +// Email::delete_filter filtername= [account=] (2B-3-pre fact 8). +// This is the ROLLBACK primitive: the only filter deletes the tool ever +// emits are the inverses of its own applied creates. +func DeleteFilter(ctx context.Context, c Runner, filtername, account string) error { + args := map[string]string{"filtername": filtername} + if account != "" { + args["account"] = account + } + _, err := RunUAPI[json.RawMessage](ctx, c, "Email", "delete_filter", args) + if err != nil { + return err + } + logx.Debug("DeleteFilter(%q, account=%q): ok", filtername, account) + return nil +} + +// SetMXCheck sets the mail-routing mode for a domain via API2 +// Email::setmxcheck (no UAPI equivalent, 2B-3-pre fact 11). +// Valid mxcheck values: local, remote, auto, secondary. +// WARNING: This is a DESTRUCTIVE set: the apply guard must re-check the +// current value immediately before calling (same pattern as +// set_default_address). +func SetMXCheck(ctx context.Context, c Runner, domain, mxcheck string) error { + _, err := RunAPI2[json.RawMessage](ctx, c, "Email", "setmxcheck", map[string]string{ + "domain": domain, + "mxcheck": mxcheck, + }) + if err != nil { + return err + } + logx.Debug("SetMXCheck(%s -> %s): ok", domain, mxcheck) + return nil +} + +// ListMXsWithRaw is ListMXs plus the verbatim response bytes, for the +// pre-write backup. +func ListMXsWithRaw(ctx context.Context, c Runner) ([]MailRoutingEntry, []byte, error) { + data, raw, err := RunUAPIRaw[[]MailRoutingEntry](ctx, c, "Email", "list_mxs", nil) + if err != nil { + return nil, nil, err + } + logx.Debug("ListMXsWithRaw: %d domain(s)", len(data)) + return data, raw, nil +} + +// ListEmailFiltersWithRaw is ListEmailFilters plus the verbatim response +// bytes, for the pre-write backup. +func ListEmailFiltersWithRaw(ctx context.Context, c Runner, account string) ([]EmailFilterEntry, []byte, error) { + var args map[string]string + if account != "" { + args = map[string]string{"account": account} + } + data, raw, err := RunUAPIRaw[[]EmailFilterEntry](ctx, c, "Email", "list_filters", args) + if err != nil { + return nil, nil, err + } + logx.Debug("ListEmailFiltersWithRaw(%q): %d filter(s)", account, len(data)) + return data, raw, nil +} + +// ListAutorespondersWithRaw is ListAutoresponders plus the verbatim +// response bytes, for the pre-write backup (2B design: raw + normalized). +func ListAutorespondersWithRaw(ctx context.Context, c Runner, domain string) ([]AutoresponderEntry, []byte, error) { + data, raw, err := RunUAPIRaw[[]AutoresponderEntry](ctx, c, "Email", "list_auto_responders", + map[string]string{"domain": domain}) + if err != nil { + return nil, nil, err + } + sort.SliceStable(data, func(i, j int) bool { return data[i].Email < data[j].Email }) + logx.Debug("ListAutorespondersWithRaw(%s): %d autoresponder(s)", domain, len(data)) + return data, raw, nil +} + +// GetAutoresponderWithRaw is GetAutoresponder plus the verbatim response +// bytes, for the same backup purpose. +func GetAutoresponderWithRaw(ctx context.Context, c Runner, email string) (AutoresponderDetail, []byte, error) { + data, raw, err := RunUAPIRaw[AutoresponderDetail](ctx, c, "Email", "get_auto_responder", + map[string]string{"email": email}) + if err != nil { + return AutoresponderDetail{}, nil, err + } + logx.Debug("GetAutoresponderWithRaw(%s): %d body byte(s)", email, len(data.Body)) + return data, raw, nil +} + +// ListForwardersWithRaw is the fresh re-list primitive of the email apply +// freshness guard: like ListForwarders, but it also returns the VERBATIM +// UAPI response bytes so the pre-write backup can archive the raw server +// state alongside the normalized entries (2B design: backup-or-nothing). +func ListForwardersWithRaw(ctx context.Context, c Runner, domain string) ([]ForwarderEntry, []byte, error) { + data, raw, err := RunUAPIRaw[[]ForwarderEntry](ctx, c, "Email", "list_forwarders", + map[string]string{"domain": domain}) + if err != nil { + return nil, nil, err + } + sort.SliceStable(data, func(i, j int) bool { return data[i].Dest < data[j].Dest }) + logx.Debug("ListForwardersWithRaw(%s): %d forwarder(s)", domain, len(data)) + return data, raw, nil +} + +// ListDefaultAddressesWithRaw is ListDefaultAddresses plus the verbatim +// response bytes, for the same backup purpose. +func ListDefaultAddressesWithRaw(ctx context.Context, c Runner) ([]DefaultAddressEntry, []byte, error) { + data, raw, err := RunUAPIRaw[[]DefaultAddressEntry](ctx, c, "Email", "list_default_address", nil) + if err != nil { + return nil, nil, err + } + sort.SliceStable(data, func(i, j int) bool { return data[i].Domain < data[j].Domain }) + logx.Debug("ListDefaultAddressesWithRaw: %d domain(s)", len(data)) + return data, raw, nil +} + +// ForwarderExists reports whether the exact pair is present +// (case-insensitive), the comparison the apply verify-after uses. +func ForwarderExists(entries []ForwarderEntry, address, target string) bool { + a, tgt := strings.ToLower(strings.TrimSpace(address)), strings.ToLower(strings.TrimSpace(target)) + for _, e := range entries { + if strings.ToLower(strings.TrimSpace(e.Dest)) == a && strings.ToLower(strings.TrimSpace(e.Forward)) == tgt { + return true + } + } + return false +} diff --git a/internal/cpanel/email_apply_safety_test.go b/internal/cpanel/email_apply_safety_test.go new file mode 100644 index 00000000..60c470c9 --- /dev/null +++ b/internal/cpanel/email_apply_safety_test.go @@ -0,0 +1,193 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "fmt" + "go/ast" + "go/parser" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + goscanner "go/scanner" + "go/token" +) + +// emailWriteForbidden are the email-config write primitives no source may +// mention outside the allowlisted writer files: the 2B-1 UAPI writers plus +// the 2B-2/2B-3 primitives reserved for their own future PRs. +var emailWriteForbidden = []string{ + "add_forwarder", + "set_default_address", + "delete_forwarder", + "add_auto_responder", + "delete_auto_responder", + "store_filter", + "delete_filter", + "setmxcheck", +} + +// emailWriteAllowlist names the ONLY files that may reference the email +// write verbs, the writer primitives and the `email apply` command that +// drives them (which also implements --rollback). This is the first +// per-file allowlist in a module-wide scan (the DNS scan has none yet; +// 6D will introduce its own the same way). Amending this list is a +// conscious, reviewed act: that is the point of the test. +var emailWriteAllowlist = map[string]bool{ + "internal/cpanel/email_apply.go": true, + "cmd/cpanel-self-migration/email_apply_cmd.go": true, +} + +// TestNoEmailWritePatternsModuleWide extends the email write-verb scan to +// the whole module (mirror of TestNoDNSWritePatternsModuleWide): the plan +// builder, verify logic and every other source must never reference an +// email write primitive in code. Comments are exempt (design references +// legitimately NAME the API); the guarded property is that no code can +// CALL it, and a call requires the name in a string literal (RunUAPI +// argv) or an identifier, so the scan tokenizes each file and checks +// only those tokens. +// +// Known limit: a name built at runtime defeats a lexical scan by +// construction. TestDNSAPICallsUseLiteralNames closes that hole for the +// RunUAPI/RunAPI2/RunUAPIRaw entry points by REQUIRING literal +// module/function arguments, the concatenation itself becomes a failure. +func TestNoEmailWritePatternsModuleWide(t *testing.T) { + root := "../.." // module root from internal/cpanel + rootAbs, err := filepath.Abs(root) + if err != nil { + t.Fatal(err) + } + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if d.Name() == ".git" || d.Name() == "vendor" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + abs, err := filepath.Abs(path) + if err != nil { + return err + } + rel, err := filepath.Rel(rootAbs, abs) + if err != nil { + return err + } + if emailWriteAllowlist[filepath.ToSlash(rel)] { + return nil + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + fset := token.NewFileSet() + f := fset.AddFile(path, fset.Base(), len(b)) + var s goscanner.Scanner + s.Init(f, b, nil, 0) // mode 0: comments are not returned + for { + pos, tok, lit := s.Scan() + if tok == token.EOF { + break + } + if tok != token.STRING && tok != token.CHAR && tok != token.IDENT { + continue + } + for _, pattern := range emailWriteForbidden { + if strings.Contains(lit, pattern) { + t.Errorf("%s: %s token %q contains forbidden email write pattern %q (allowlist: email_apply.go + email_apply_cmd.go only)", + fset.Position(pos), tok, lit, pattern) + } + } + } + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } +} + +// TestEmailWriteAllowlistFilesExist pins the allowlist against silent rot: +// an allowlisted path that no longer exists means the writer moved and the +// scan is guarding the wrong file. +func TestEmailWriteAllowlistFilesExist(t *testing.T) { + for rel := range emailWriteAllowlist { + p := filepath.Join("../..", filepath.FromSlash(rel)) + if _, err := os.Stat(p); err != nil { + t.Errorf("allowlisted file %s does not exist: %v", rel, err) + } + } +} + +// emailWriteWrapperFns are the exported Go wrappers around the email write +// primitives. The string-pattern scan above is on the snake_case UAPI/API2 +// verbs and never sees these CamelCase identifiers, so a call to a wrapper +// from a non-allowlisted file would slip past the guard (the same gap the +// DNS/cron guards close for MassEditZone*/InstallCrontab). +var emailWriteWrapperFns = map[string]bool{ + "AddForwarder": true, + "DeleteForwarder": true, + "SetDefaultAddress": true, + "AddAutoresponder": true, + "DeleteAutoresponder": true, + "StoreFilter": true, + "DeleteFilter": true, + "SetMXCheck": true, +} + +// TestNoEmailWriteWrapperCallsOutsideAllowlist fails if any non-allowlisted +// production file calls an email write wrapper. Complements the string scan +// (which the CamelCase wrapper identifiers evade) so the write path stays +// funneled through the reviewed allowlist. Reuses calleeBaseName (dns guard). +func TestNoEmailWriteWrapperCallsOutsideAllowlist(t *testing.T) { + root := "../.." // module root from internal/cpanel + fset := token.NewFileSet() + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if d.Name() == ".git" || d.Name() == "vendor" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if emailWriteAllowlist[filepath.ToSlash(rel)] { + return nil + } + f, err := parser.ParseFile(fset, path, nil, 0) + if err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + ast.Inspect(f, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if name := calleeBaseName(call.Fun); emailWriteWrapperFns[name] { + t.Errorf("%s: calls email write wrapper %s outside the allowlist; add the file to emailWriteAllowlist only after review", + fset.Position(call.Pos()), name) + } + return true + }) + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } +} diff --git a/internal/cpanel/email_apply_test.go b/internal/cpanel/email_apply_test.go new file mode 100644 index 00000000..f3961700 --- /dev/null +++ b/internal/cpanel/email_apply_test.go @@ -0,0 +1,245 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "strings" + "testing" +) + +// --- write primitives (2B-pre byte-verified contract) ----------------------- + +func TestAddForwarderBuildsVerifiedCall(t *testing.T) { + f := &fakeRunner{out: uapiOK(`{"forward":"someone@gmail.com","domain":"example.com","email":"info@example.com"}`)} + if err := AddForwarder(bg, f, "example.com", "info", "someone@gmail.com"); err != nil { + t.Fatalf("AddForwarder: %v", err) + } + if !strings.Contains(f.script, "uapi --output=json Email add_forwarder") { + t.Errorf("script = %q", f.script) + } + // 2B-pre contract: domain= email= fwdopt=fwd fwdemail=; + // values travel via env, never spliced into the script. + for _, k := range []string{"domain", "email", "fwdopt", "fwdemail"} { + if !strings.Contains(f.script, k+`="$ARG_`) { + t.Errorf("script missing env-backed %s= arg: %q", k, f.script) + } + } + wantEnv := map[string]string{"domain": "example.com", "email": "info", "fwdopt": "fwd", "fwdemail": "someone@gmail.com"} + envByKey := envArgsByKey(t, f.script, f.env) + for k, v := range wantEnv { + if envByKey[k] != v { + t.Errorf("arg %s = %q, want %q (env %v)", k, envByKey[k], v, f.env) + } + } +} + +func TestDeleteForwarderBuildsVerifiedCall(t *testing.T) { + f := &fakeRunner{out: uapiOK(`null`)} + if err := DeleteForwarder(bg, f, "info@example.com", "someone@gmail.com"); err != nil { + t.Fatalf("DeleteForwarder: %v", err) + } + if !strings.Contains(f.script, "uapi --output=json Email delete_forwarder") { + t.Errorf("script = %q", f.script) + } + envByKey := envArgsByKey(t, f.script, f.env) + if envByKey["address"] != "info@example.com" || envByKey["forwarder"] != "someone@gmail.com" { + t.Errorf("args = %v", envByKey) + } +} + +func TestSetDefaultAddressForms(t *testing.T) { + cases := []struct { + name string + value string + want map[string]string + wantAbs []string // keys that must NOT be present + }{ + { + name: "plain address", + value: "someone@gmail.com", + want: map[string]string{"domain": "example.com", "fwdopt": "fwd", "fwdemail": "someone@gmail.com"}, + }, + { + name: "fail system form", + value: ":fail: No Such User Here", + want: map[string]string{"domain": "example.com", "fwdopt": "fail", "failmsgs": "No Such User Here"}, + wantAbs: []string{"fwdemail"}, + }, + { + name: "blackhole system form", + value: ":blackhole:", + want: map[string]string{"domain": "example.com", "fwdopt": "blackhole"}, + wantAbs: []string{"fwdemail", "failmsgs"}, + }, + { + name: "bare username (deliver to account)", + value: "acctuser", + want: map[string]string{"domain": "example.com", "fwdopt": "fwd", "fwdemail": "acctuser"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := &fakeRunner{out: uapiOK(`[{"dest":"x","domain":"example.com"}]`)} + if err := SetDefaultAddress(bg, f, "example.com", tc.value); err != nil { + t.Fatalf("SetDefaultAddress: %v", err) + } + if !strings.Contains(f.script, "uapi --output=json Email set_default_address") { + t.Errorf("script = %q", f.script) + } + envByKey := envArgsByKey(t, f.script, f.env) + for k, v := range tc.want { + if envByKey[k] != v { + t.Errorf("arg %s = %q, want %q", k, envByKey[k], v) + } + } + for _, k := range tc.wantAbs { + if _, ok := envByKey[k]; ok { + t.Errorf("arg %s must be absent, got %q", k, envByKey[k]) + } + } + }) + } +} + +func TestEmailWritePrimitivesSurfaceUAPIErrors(t *testing.T) { + f := &fakeRunner{out: uapiFail("denied")} + if err := AddForwarder(bg, f, "d.com", "i", "x@y.com"); err == nil { + t.Error("AddForwarder must surface a UAPI failure") + } + if err := DeleteForwarder(bg, f, "i@d.com", "x@y.com"); err == nil { + t.Error("DeleteForwarder must surface a UAPI failure") + } + if err := SetDefaultAddress(bg, f, "d.com", "x@y.com"); err == nil { + t.Error("SetDefaultAddress must surface a UAPI failure") + } +} + +// --- fresh re-list primitives (raw + normalized, for the apply backup) ------ + +func TestListForwardersWithRaw(t *testing.T) { + raw := uapiOK(`[{"dest":"info@d.com","forward":"x@y.com"}]`) + f := &fakeRunner{out: raw} + entries, rawOut, err := ListForwardersWithRaw(bg, f, "d.com") + if err != nil { + t.Fatalf("ListForwardersWithRaw: %v", err) + } + if len(entries) != 1 || entries[0].Dest != "info@d.com" { + t.Errorf("entries = %+v", entries) + } + if string(rawOut) != string(raw) { + t.Errorf("raw = %q, want the verbatim response", rawOut) + } +} + +func TestListDefaultAddressesWithRaw(t *testing.T) { + raw := uapiOK(`[{"domain":"d.com","defaultaddress":"acct"}]`) + f := &fakeRunner{out: raw} + entries, rawOut, err := ListDefaultAddressesWithRaw(bg, f) + if err != nil { + t.Fatalf("ListDefaultAddressesWithRaw: %v", err) + } + if len(entries) != 1 || entries[0].DefaultAddress != "acct" { + t.Errorf("entries = %+v", entries) + } + if string(rawOut) != string(raw) { + t.Errorf("raw = %q, want the verbatim response", rawOut) + } +} + +// envArgsByKey maps each `key="$ARG_i"` in the script back to its env +// value, so tests assert on the actual uapi arguments. +func envArgsByKey(t *testing.T, script string, env map[string]string) map[string]string { + t.Helper() + out := map[string]string{} + for _, tok := range strings.Fields(script) { + k, v, ok := strings.Cut(tok, "=") + if !ok || !strings.HasPrefix(v, `"$ARG_`) { + continue + } + ev := strings.TrimSuffix(strings.TrimPrefix(v, `"$`), `"`) + out[k] = env[ev] + } + return out +} + +// --- autoresponder write primitives (2B-2-pre byte-verified contract) -------- + +func TestAddAutoresponderBuildsVerifiedCall(t *testing.T) { + f := &fakeRunner{out: uapiOK(`null`)} + w := AutoresponderWrite{ + From: "Info Desk", Subject: "Out of office", + Body: "On vacation.\n", IsHTML: 0, Interval: 8, Charset: "utf-8", + } + if err := AddAutoresponder(bg, f, "example.com", "info", w); err != nil { + t.Fatalf("AddAutoresponder: %v", err) + } + if !strings.Contains(f.script, "uapi --output=json Email add_auto_responder") { + t.Errorf("script = %q", f.script) + } + envByKey := envArgsByKey(t, f.script, f.env) + want := map[string]string{ + "domain": "example.com", "email": "info", "from": "Info Desk", + "subject": "Out of office", "body": "On vacation.\n", + "is_html": "0", "interval": "8", "charset": "utf-8", + } + for k, v := range want { + if envByKey[k] != v { + t.Errorf("arg %s = %q, want %q", k, envByKey[k], v) + } + } + // start/stop unset (0) must be OMITTED: the byte-verified add stores + // null when they are absent (2B-2-pre fact 1). + for _, k := range []string{"start", "stop"} { + if _, present := envByKey[k]; present { + t.Errorf("arg %s must be omitted when zero", k) + } + } +} + +func TestAddAutoresponderIncludesStartStopWhenSet(t *testing.T) { + f := &fakeRunner{out: uapiOK(`null`)} + w := AutoresponderWrite{ + From: "T", Subject: "s", Body: "b\n", IsHTML: 1, Interval: 12, + Start: 1783062169, Stop: 1783666969, Charset: "utf-8", + } + if err := AddAutoresponder(bg, f, "example.com", "info", w); err != nil { + t.Fatalf("AddAutoresponder: %v", err) + } + envByKey := envArgsByKey(t, f.script, f.env) + if envByKey["start"] != "1783062169" || envByKey["stop"] != "1783666969" { + t.Errorf("start/stop = %q/%q", envByKey["start"], envByKey["stop"]) + } + if envByKey["is_html"] != "1" { + t.Errorf("is_html = %q", envByKey["is_html"]) + } +} + +func TestDeleteAutoresponderBuildsVerifiedCall(t *testing.T) { + f := &fakeRunner{out: uapiOK(`null`)} + if err := DeleteAutoresponder(bg, f, "info@example.com"); err != nil { + t.Fatalf("DeleteAutoresponder: %v", err) + } + if !strings.Contains(f.script, "uapi --output=json Email delete_auto_responder") { + t.Errorf("script = %q", f.script) + } + envByKey := envArgsByKey(t, f.script, f.env) + if envByKey["email"] != "info@example.com" { + t.Errorf("args = %v", envByKey) + } +} + +func TestListAutorespondersWithRawReturnsVerbatimBytes(t *testing.T) { + raw := uapiOK(`[{"email":"b@example.com","subject":"B"},{"email":"a@example.com","subject":"A"}]`) + f := &fakeRunner{out: raw} + entries, got, err := ListAutorespondersWithRaw(bg, f, "example.com") + if err != nil { + t.Fatalf("ListAutorespondersWithRaw: %v", err) + } + if string(got) != string(raw) { + t.Errorf("raw bytes not verbatim") + } + if len(entries) != 2 || entries[0].Email != "a@example.com" { + t.Errorf("entries = %+v, want sorted by email", entries) + } +} diff --git a/internal/cpanel/email_config.go b/internal/cpanel/email_config.go new file mode 100644 index 00000000..4f69ddae --- /dev/null +++ b/internal/cpanel/email_config.go @@ -0,0 +1,93 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "sort" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +type ForwarderEntry struct { + Dest string `json:"dest"` + Forward string `json:"forward"` +} + +// AutoresponderEntry is one list_auto_responders row. Real servers return +// ONLY {email, subject}, with email as the FULL address (2B-2-pre fact 2); +// the remaining fields exist for tolerance of hypothetical richer builds +// and were never observed non-empty on live cPanel, every detail +// (body, from, interval, is_html, start, stop) comes from +// get_auto_responder (AutoresponderDetail). +type AutoresponderEntry struct { + Email string `json:"email"` + From string `json:"from"` + Subject string `json:"subject"` + Body string `json:"body"` + Domain string `json:"domain"` + // interval / is_html / start / stop are flexInt64 as defensive hardening: + // this exact "int field returned as a quoted string/float" shape has + // already broken FTP diskused, email _diskused and MySQL disk_usage on the + // live server. + Interval flexInt64 `json:"interval"` + IsHTML flexInt64 `json:"is_html"` + Start flexInt64 `json:"start"` + Stop flexInt64 `json:"stop"` +} + +// AutoresponderDetail is the get_auto_responder response (2B-2-pre fact 3): +// the ONLY source of an autoresponder's body/from/interval/is_html/start/ +// stop. start/stop arrive as JSON null when unset (flexInt64 -> 0). NOTE +// (fact 4): an address WITHOUT an autoresponder still returns status:1 +// with data:{charset}, existence must be gated on list_auto_responders, +// never on this call. +type AutoresponderDetail struct { + From string `json:"from"` + Subject string `json:"subject"` + Body string `json:"body"` + Charset string `json:"charset"` + // interval/is_html arrive as bare numbers on the live server; flexInt64 + // is the house defensive default for informational numerics. + Interval flexInt64 `json:"interval"` + IsHTML flexInt64 `json:"is_html"` + Start flexInt64 `json:"start"` + Stop flexInt64 `json:"stop"` +} + +func ListForwarders(ctx context.Context, c Runner, domain string) ([]ForwarderEntry, error) { + data, err := RunUAPI[[]ForwarderEntry](ctx, c, "Email", "list_forwarders", + map[string]string{"domain": domain}) + if err != nil { + return nil, err + } + sort.SliceStable(data, func(i, j int) bool { return data[i].Dest < data[j].Dest }) + logx.Debug("ListForwarders(%s): %d forwarder(s)", domain, len(data)) + return data, nil +} + +func ListAutoresponders(ctx context.Context, c Runner, domain string) ([]AutoresponderEntry, error) { + data, err := RunUAPI[[]AutoresponderEntry](ctx, c, "Email", "list_auto_responders", + map[string]string{"domain": domain}) + if err != nil { + return nil, err + } + sort.SliceStable(data, func(i, j int) bool { return data[i].Email < data[j].Email }) + logx.Debug("ListAutoresponders(%s): %d autoresponder(s)", domain, len(data)) + return data, nil +} + +// GetAutoresponder fetches one autoresponder's full content: +// Email::get_auto_responder email= (read-only). Callers must +// gate existence on ListAutoresponders first: an absent autoresponder +// still answers status:1 with a charset-only body (2B-2-pre fact 4). +func GetAutoresponder(ctx context.Context, c Runner, email string) (AutoresponderDetail, error) { + data, err := RunUAPI[AutoresponderDetail](ctx, c, "Email", "get_auto_responder", + map[string]string{"email": email}) + if err != nil { + return AutoresponderDetail{}, err + } + logx.Debug("GetAutoresponder(%s): %d body byte(s)", email, len(data.Body)) + return data, nil +} diff --git a/internal/cpanel/email_config_test.go b/internal/cpanel/email_config_test.go new file mode 100644 index 00000000..0aaf4b6b --- /dev/null +++ b/internal/cpanel/email_config_test.go @@ -0,0 +1,71 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "testing" +) + +func TestParseListForwarders(t *testing.T) { + data, err := parseUAPI[[]ForwarderEntry]("Email", "list_forwarders", fixture(t, "email_forwarders.json")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 2 { + t.Fatalf("got %d forwarders, want 2", len(data)) + } + if data[0].Dest != "info@main.example" { + t.Errorf("[0] dest = %q, want %q", data[0].Dest, "info@main.example") + } + if data[0].Forward != "admin@gmail.com" { + t.Errorf("[0] forward = %q, want %q", data[0].Forward, "admin@gmail.com") + } + if data[1].Forward != "sales@company.com, backup@company.com" { + t.Errorf("[1] forward = %q", data[1].Forward) + } +} + +func TestParseListForwardersEmpty(t *testing.T) { + empty := []byte(`{"result":{"data":[],"errors":null,"messages":null,"status":1}}`) + data, err := parseUAPI[[]ForwarderEntry]("Email", "list_forwarders", empty) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 0 { + t.Errorf("got %d, want 0", len(data)) + } +} + +func TestParseListAutoresponders(t *testing.T) { + data, err := parseUAPI[[]AutoresponderEntry]("Email", "list_auto_responders", fixture(t, "email_autoresponders.json")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 1 { + t.Fatalf("got %d autoresponders, want 1", len(data)) + } + if data[0].Email != "info" { + t.Errorf("email = %q, want %q", data[0].Email, "info") + } + if data[0].Domain != "main.example" { + t.Errorf("domain = %q, want %q", data[0].Domain, "main.example") + } + if data[0].Subject != "Out of office" { + t.Errorf("subject = %q", data[0].Subject) + } + if int(data[0].Interval) != 24 { + t.Errorf("interval = %d, want 24", int(data[0].Interval)) + } +} + +func TestParseListAutorespondersEmpty(t *testing.T) { + empty := []byte(`{"result":{"data":[],"errors":null,"messages":null,"status":1}}`) + data, err := parseUAPI[[]AutoresponderEntry]("Email", "list_auto_responders", empty) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 0 { + t.Errorf("got %d, want 0", len(data)) + } +} diff --git a/internal/cpanel/email_default_address.go b/internal/cpanel/email_default_address.go new file mode 100644 index 00000000..4053eeec --- /dev/null +++ b/internal/cpanel/email_default_address.go @@ -0,0 +1,33 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "sort" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +// DefaultAddressEntry is one domain's catch-all configuration. The +// value is kept verbatim (the cPanel default `":fail: No Such User +// Here"` embeds literal double quotes) and must be compared as an +// opaque string. +type DefaultAddressEntry struct { + Domain string `json:"domain"` + DefaultAddress string `json:"defaultaddress"` +} + +// ListDefaultAddresses returns the default (catch-all) address of every +// domain on the account, subdomains included, in a single call +// (read-only). +func ListDefaultAddresses(ctx context.Context, c Runner) ([]DefaultAddressEntry, error) { + data, err := RunUAPI[[]DefaultAddressEntry](ctx, c, "Email", "list_default_address", nil) + if err != nil { + return nil, err + } + sort.SliceStable(data, func(i, j int) bool { return data[i].Domain < data[j].Domain }) + logx.Debug("ListDefaultAddresses: %d domain(s)", len(data)) + return data, nil +} diff --git a/internal/cpanel/email_default_address_test.go b/internal/cpanel/email_default_address_test.go new file mode 100644 index 00000000..f02ed5d0 --- /dev/null +++ b/internal/cpanel/email_default_address_test.go @@ -0,0 +1,17 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import "testing" + +func TestParseListDefaultAddressesEmpty(t *testing.T) { + empty := []byte(`{"result":{"data":[],"errors":null,"messages":null,"status":1}}`) + data, err := parseUAPI[[]DefaultAddressEntry]("Email", "list_default_address", empty) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 0 { + t.Errorf("got %d, want 0", len(data)) + } +} diff --git a/internal/cpanel/email_filters.go b/internal/cpanel/email_filters.go new file mode 100644 index 00000000..5f0c6f9d --- /dev/null +++ b/internal/cpanel/email_filters.go @@ -0,0 +1,102 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "encoding/json" + "sort" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +// EmailFilterEntry is one email filter from Email::list_filters. The +// non-empty item shape is docs-derived (no reachable production account +// has filters, PR7E_PRE_CAPTURES.md fact 3), so rules and actions are +// retained as raw JSON: consumers use their COUNTS only, and a shape +// surprise inside a rule body cannot break the decode. If `rules` or +// `actions` ever arrives as a non-array, the decode errors and the +// section degrades to unavailable+warning, fail-safe, never silent. +type EmailFilterEntry struct { + FilterName string `json:"filtername"` + Enabled flexInt64 `json:"enabled"` // observed as int and quoted string in docs examples + Rules []json.RawMessage `json:"rules"` + Actions []json.RawMessage `json:"actions"` +} + +// FilterRuleDecoded is a get_filter rule entry with typed fields. +type FilterRuleDecoded struct { + Part string `json:"part"` + Match string `json:"match"` + Opt any `json:"opt"` + Val string `json:"val"` + Number int `json:"number"` +} + +// FilterActionDecoded is a get_filter action entry with typed fields. +type FilterActionDecoded struct { + Action string `json:"action"` + Dest *string `json:"dest"` + Number int `json:"number"` +} + +// GetEmailFilterResult is the decoded get_filter response. The response +// shape includes a `number` field per rule/action (positional index) and +// `opt` (always null in all observed responses, 2B-3-pre fact 3). +// Rules and Actions are retained as raw JSON so consumers are not broken +// by shape surprises in the rule/action bodies. +type GetEmailFilterResult struct { + FilterName string `json:"filtername"` + Rules []json.RawMessage `json:"rules"` + Actions []json.RawMessage `json:"actions"` +} + +// GetEmailFilter returns a single filter by name (read-only). +// On a NON-EXISTENT filter, cPanel returns status:1 with a TEMPLATE +// response (filtername="Rule 1", 1 empty rule, 1 empty action), NOT an +// error (2B-3-pre fact 4). Callers must gate existence on list_filters. +func GetEmailFilter(ctx context.Context, c Runner, filtername, account string) (GetEmailFilterResult, error) { + args := map[string]string{"filtername": filtername} + if account != "" { + args["account"] = account + } + data, err := RunUAPI[GetEmailFilterResult](ctx, c, "Email", "get_filter", args) + if err != nil { + return GetEmailFilterResult{}, err + } + logx.Debug("GetEmailFilter(%q, %q): rules=%d actions=%d", filtername, account, len(data.Rules), len(data.Actions)) + return data, nil +} + +// ListEmailFilters returns the filters of one scope (read-only): +// account == "" is the account-level (all mail) filter set, otherwise +// the per-mailbox set of that email address. +func ListEmailFilters(ctx context.Context, c Runner, account string) ([]EmailFilterEntry, error) { + var args map[string]string + if account != "" { + args = map[string]string{"account": account} + } + data, err := RunUAPI[[]EmailFilterEntry](ctx, c, "Email", "list_filters", args) + if err != nil { + return nil, err + } + // Tie-break beyond the name: duplicate filter names are possible in + // a hand-edited filter file and the backend order is not proven + // stable across invocations. + sort.SliceStable(data, func(i, j int) bool { + a, b := data[i], data[j] + if a.FilterName != b.FilterName { + return a.FilterName < b.FilterName + } + if a.Enabled != b.Enabled { + return a.Enabled < b.Enabled + } + if len(a.Rules) != len(b.Rules) { + return len(a.Rules) < len(b.Rules) + } + return len(a.Actions) < len(b.Actions) + }) + logx.Debug("ListEmailFilters(%q): %d filter(s)", account, len(data)) + return data, nil +} diff --git a/internal/cpanel/email_filters_test.go b/internal/cpanel/email_filters_test.go new file mode 100644 index 00000000..8008b47d --- /dev/null +++ b/internal/cpanel/email_filters_test.go @@ -0,0 +1,110 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import "testing" + +// email_list_filters.json is SYNTHETIC (docs-derived): no reachable +// production account has filters (PR7E_PRE_CAPTURES.md fact 3), so the +// non-empty item shape is unproven. The decoder therefore keeps rules +// and actions as raw JSON, only their COUNTS are consumed, and the +// test pins that a shape surprise inside a rule body cannot break the +// decode. `enabled` exercises flexInt64 (1 and quoted "0"). +func TestParseListEmailFiltersSynthetic(t *testing.T) { + data, err := parseUAPI[[]EmailFilterEntry]("Email", "list_filters", fixture(t, "email_list_filters.json")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 2 { + t.Fatalf("got %d filters, want 2", len(data)) + } + if data[0].FilterName != "spam-to-junk" || data[0].Enabled != 1 { + t.Errorf("[0] = %q enabled=%d, want spam-to-junk enabled=1", data[0].FilterName, data[0].Enabled) + } + if len(data[0].Rules) != 1 || len(data[0].Actions) != 1 { + t.Errorf("[0] rules/actions = %d/%d, want 1/1", len(data[0].Rules), len(data[0].Actions)) + } + if data[1].FilterName != "legacy-disabled" || data[1].Enabled != 0 { + t.Errorf("[1] = %q enabled=%d, want legacy-disabled enabled=0 (from quoted \"0\")", + data[1].FilterName, data[1].Enabled) + } + if len(data[1].Rules) != 2 || len(data[1].Actions) != 2 { + t.Errorf("[1] rules/actions = %d/%d, want 2/2", len(data[1].Rules), len(data[1].Actions)) + } +} + +// Byte-verified live shape: every reachable account returns data:[]. +func TestParseListEmailFiltersEmpty(t *testing.T) { + empty := []byte(`{"result":{"errors":null,"data":[],"warnings":null,"messages":null,"status":1,"metadata":{"transformed":1}}}`) + data, err := parseUAPI[[]EmailFilterEntry]("Email", "list_filters", empty) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 0 { + t.Errorf("got %d, want 0", len(data)) + } +} + +// A rule whose body is an OBJECT with unexpected fields (or any other +// JSON value) must still count, raw retention, no inner binding. +func TestParseListEmailFiltersUnknownRuleShape(t *testing.T) { + odd := []byte(`{"result":{"data":[{"filtername":"odd","enabled":"1","rules":[{"totally":"unknown","nested":{"x":1}},"even-a-string"],"actions":[]}],"errors":null,"messages":null,"status":1}}`) + data, err := parseUAPI[[]EmailFilterEntry]("Email", "list_filters", odd) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 1 || len(data[0].Rules) != 2 || len(data[0].Actions) != 0 { + t.Errorf("got %+v, want 1 filter with 2 raw rules and 0 actions", data) + } + if data[0].Enabled != 1 { + t.Errorf("enabled = %d, want 1 (from quoted \"1\")", data[0].Enabled) + } +} + +// 2B-3: get_filter with a real filter returns typed rule/action data. +func TestParseGetEmailFilterReal(t *testing.T) { + data, err := parseUAPI[GetEmailFilterResult]("Email", "get_filter", fixture(t, "email_get_filter_spam-to-junk.json")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if data.FilterName != "spam-to-junk" { + t.Errorf("filtername = %q, want spam-to-junk", data.FilterName) + } + if len(data.Rules) != 1 || len(data.Actions) != 1 { + t.Fatalf("rules/actions = %d/%d, want 1/1", len(data.Rules), len(data.Actions)) + } +} + +// 2B-3-pre fact 4: get_filter on a non-existent filter returns a +// template with filtername="Rule 1", status:1, NOT an error. +func TestParseGetEmailFilterNonExistent(t *testing.T) { + resp := []byte(`{"result":{"data":{"filtername":"Rule 1","rules":[{"number":1}],"actions":[{"number":1}]},"errors":null,"warnings":null,"status":1,"messages":null,"metadata":{}}}`) + data, err := parseUAPI[GetEmailFilterResult]("Email", "get_filter", resp) + if err != nil { + t.Fatalf("parse: %v", err) + } + if data.FilterName != "Rule 1" { + t.Errorf("filtername = %q, want template 'Rule 1'", data.FilterName) + } +} + +// Tie-break regression lock (round-2 reviewer): duplicate filter names +// must order deterministically regardless of the input order. +func TestListEmailFiltersTieBreakOrderIndependent(t *testing.T) { + oneRule := `{"filtername":"dup","enabled":1,"rules":[{}],"actions":[{}]}` + twoRules := `{"filtername":"dup","enabled":1,"rules":[{},{}],"actions":[{}]}` + for name, payload := range map[string]string{ + "one-first": oneRule + "," + twoRules, + "two-first": twoRules + "," + oneRule, + } { + out := []byte(`{"result":{"data":[` + payload + `],"errors":null,"messages":null,"status":1}}`) + data, err := ListEmailFilters(t.Context(), &fakeRunner{out: out}, "") + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(data) != 2 || len(data[0].Rules) != 1 { + t.Errorf("%s: order not deterministic, got %+v", name, data) + } + } +} diff --git a/internal/cpanel/email_routing.go b/internal/cpanel/email_routing.go new file mode 100644 index 00000000..f8a0daa3 --- /dev/null +++ b/internal/cpanel/email_routing.go @@ -0,0 +1,55 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "sort" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +// MXEntry is one MX record row inside a MailRoutingEntry. +type MXEntry struct { + Domain string `json:"domain"` + MX string `json:"mx"` + Priority flexInt64 `json:"priority"` // quoted string "0" on the live server +} + +// MailRoutingEntry is one domain's mail-routing configuration from +// Email::list_mxs. Only mail-routing domains are returned, subdomains +// do not appear even when they carry a default address. +type MailRoutingEntry struct { + Domain string `json:"domain"` + MXCheck string `json:"mxcheck"` // configured: local | remote | auto | secondary + Detected string `json:"detected"` // what cPanel detects from the MX records + // Bare ints on the capture server; flexInt64 as defensive hardening + // per the established quoted-string/float lesson. + Local flexInt64 `json:"local"` + Remote flexInt64 `json:"remote"` + Secondary flexInt64 `json:"secondary"` + AlwaysAccept flexInt64 `json:"alwaysaccept"` + Entries []MXEntry `json:"entries"` +} + +// ListMXs returns the mail-routing configuration of every mail-routing +// domain on the account (read-only). +func ListMXs(ctx context.Context, c Runner) ([]MailRoutingEntry, error) { + data, err := RunUAPI[[]MailRoutingEntry](ctx, c, "Email", "list_mxs", nil) + if err != nil { + return nil, err + } + sort.SliceStable(data, func(i, j int) bool { return data[i].Domain < data[j].Domain }) + for i := range data { + e := data[i].Entries + sort.SliceStable(e, func(a, b int) bool { + if e[a].Priority != e[b].Priority { + return e[a].Priority < e[b].Priority + } + return e[a].MX < e[b].MX + }) + } + logx.Debug("ListMXs: %d domain(s)", len(data)) + return data, nil +} diff --git a/internal/cpanel/email_routing_test.go b/internal/cpanel/email_routing_test.go new file mode 100644 index 00000000..6c3fbf34 --- /dev/null +++ b/internal/cpanel/email_routing_test.go @@ -0,0 +1,17 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import "testing" + +func TestParseListMXsEmpty(t *testing.T) { + empty := []byte(`{"result":{"data":[],"errors":null,"messages":null,"status":1}}`) + data, err := parseUAPI[[]MailRoutingEntry]("Email", "list_mxs", empty) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 0 { + t.Errorf("got %d, want 0", len(data)) + } +} diff --git a/internal/cpanel/ftp.go b/internal/cpanel/ftp.go new file mode 100644 index 00000000..44e1b511 --- /dev/null +++ b/internal/cpanel/ftp.go @@ -0,0 +1,32 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "sort" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +type FTPAccountEntry struct { + Login string `json:"login"` + AcctType string `json:"accttype"` + Dir string `json:"dir"` + RelDir string `json:"reldir"` + DiskQuota string `json:"diskquota"` + DiskUsed flexInt64 `json:"diskused"` // string "57632.08" or float 13558.40 across builds + DiskUsedPercent int `json:"diskusedpercent"` + Deleteable int `json:"deleteable"` +} + +func ListFTPAccounts(ctx context.Context, c Runner) ([]FTPAccountEntry, error) { + data, err := RunUAPI[[]FTPAccountEntry](ctx, c, "Ftp", "list_ftp_with_disk", nil) + if err != nil { + return nil, err + } + sort.SliceStable(data, func(i, j int) bool { return data[i].Login < data[j].Login }) + logx.Debug("ListFTPAccounts: %d account(s)", len(data)) + return data, nil +} diff --git a/internal/cpanel/ftp_test.go b/internal/cpanel/ftp_test.go new file mode 100644 index 00000000..eb23b424 --- /dev/null +++ b/internal/cpanel/ftp_test.go @@ -0,0 +1,41 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "testing" +) + +func TestParseListFTPAccounts(t *testing.T) { + data, err := parseUAPI[[]FTPAccountEntry]("Ftp", "list_ftp_with_disk", fixture(t, "ftp_list.json")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 2 { + t.Fatalf("got %d accounts, want 2", len(data)) + } + if data[0].Login != "main@main.example" { + t.Errorf("[0] login = %q", data[0].Login) + } + if data[0].AcctType != "main" { + t.Errorf("[0] accttype = %q", data[0].AcctType) + } + if data[1].Login != "uploads@main.example" { + t.Errorf("[1] login = %q", data[1].Login) + } + if data[1].DiskUsed != 25 { + t.Errorf("[1] diskused = %d, want 25", data[1].DiskUsed) + } +} + +func TestParseListFTPAccountsEmpty(t *testing.T) { + empty := []byte(`{"result":{"data":[],"errors":null,"messages":null,"status":1}}`) + data, err := parseUAPI[[]FTPAccountEntry]("Ftp", "list_ftp_with_disk", empty) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 0 { + t.Errorf("got %d, want 0", len(data)) + } +} diff --git a/internal/cpanel/mime_redirects.go b/internal/cpanel/mime_redirects.go new file mode 100644 index 00000000..884171fb --- /dev/null +++ b/internal/cpanel/mime_redirects.go @@ -0,0 +1,59 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "sort" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +// RedirectEntry is one redirect from Mime::list_redirects. The call +// harvests .htaccess RewriteRules, so CMS-generated rewrites dominate +// real accounts (PR7E_PRE_CAPTURES.md fact 4); classification is the +// policy layer's job, the entry keeps the raw facts. +type RedirectEntry struct { + Domain string `json:"domain"` + Source string `json:"sourceurl"` + Destination string `json:"destination"` + Kind string `json:"kind"` // "rewrite" | "redirect" + Type string `json:"type"` // "permanent" | "temporary" + StatusCode flexInt64 `json:"statuscode"` // null (->0) or quoted "301" on the live server + Wildcard flexInt64 `json:"wildcard"` + MatchWWW flexInt64 `json:"matchwww"` +} + +// ListRedirects returns every redirect/rewrite cPanel reports for the +// account (read-only). +func ListRedirects(ctx context.Context, c Runner) ([]RedirectEntry, error) { + data, err := RunUAPI[[]RedirectEntry](ctx, c, "Mime", "list_redirects", nil) + if err != nil { + return nil, err + } + // Full tie-break: the backend harvests .htaccess per docroot and its + // array order is not proven stable across invocations, so entries + // sharing Domain+Source must still order deterministically. + sort.SliceStable(data, func(i, j int) bool { + a, b := data[i], data[j] + if a.Domain != b.Domain { + return a.Domain < b.Domain + } + if a.Source != b.Source { + return a.Source < b.Source + } + if a.Destination != b.Destination { + return a.Destination < b.Destination + } + if a.Kind != b.Kind { + return a.Kind < b.Kind + } + if a.Type != b.Type { + return a.Type < b.Type + } + return a.StatusCode < b.StatusCode + }) + logx.Debug("ListRedirects: %d redirect(s)", len(data)) + return data, nil +} diff --git a/internal/cpanel/mime_redirects_test.go b/internal/cpanel/mime_redirects_test.go new file mode 100644 index 00000000..9fc72638 --- /dev/null +++ b/internal/cpanel/mime_redirects_test.go @@ -0,0 +1,38 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import "testing" + +func TestParseListRedirectsEmpty(t *testing.T) { + empty := []byte(`{"result":{"data":[],"errors":null,"messages":null,"status":1}}`) + data, err := parseUAPI[[]RedirectEntry]("Mime", "list_redirects", empty) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 0 { + t.Errorf("got %d, want 0", len(data)) + } +} + +// Tie-break regression lock (round-2 reviewer): two entries sharing +// Domain+Source must come out in the same order regardless of the +// input (API) order, which is not proven stable across invocations. +func TestListRedirectsTieBreakOrderIndependent(t *testing.T) { + entryA := `{"domain":"d.test","sourceurl":"/old","destination":"https://a.test/","kind":"rewrite","type":"permanent","statuscode":"301","wildcard":0,"matchwww":0}` + entryB := `{"domain":"d.test","sourceurl":"/old","destination":"https://b.test/","kind":"rewrite","type":"permanent","statuscode":"301","wildcard":0,"matchwww":0}` + for name, payload := range map[string]string{ + "a-first": entryA + "," + entryB, + "b-first": entryB + "," + entryA, + } { + out := []byte(`{"result":{"data":[` + payload + `],"errors":null,"messages":null,"status":1}}`) + data, err := ListRedirects(t.Context(), &fakeRunner{out: out}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(data) != 2 || data[0].Destination != "https://a.test/" { + t.Errorf("%s: order not deterministic, got %+v", name, data) + } + } +} diff --git a/internal/cpanel/php.go b/internal/cpanel/php.go new file mode 100644 index 00000000..2eb9bd1b --- /dev/null +++ b/internal/cpanel/php.go @@ -0,0 +1,31 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "sort" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +type PHPVhostEntry struct { + Vhost string `json:"vhost"` + Version string `json:"version"` + Account string `json:"account"` + DocumentRoot string `json:"documentroot"` + HomeDir string `json:"homedir"` + MainDomain int `json:"main_domain"` + IsSuspended int `json:"is_suspended"` +} + +func ListPHPVersions(ctx context.Context, c Runner) ([]PHPVhostEntry, error) { + data, err := RunUAPI[[]PHPVhostEntry](ctx, c, "LangPHP", "php_get_vhost_versions", nil) + if err != nil { + return nil, err + } + sort.SliceStable(data, func(i, j int) bool { return data[i].Vhost < data[j].Vhost }) + logx.Debug("ListPHPVersions: %d vhost(s)", len(data)) + return data, nil +} diff --git a/internal/cpanel/php_test.go b/internal/cpanel/php_test.go new file mode 100644 index 00000000..fcc3aeca --- /dev/null +++ b/internal/cpanel/php_test.go @@ -0,0 +1,41 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "testing" +) + +func TestParseListPHPVersions(t *testing.T) { + data, err := parseUAPI[[]PHPVhostEntry]("LangPHP", "php_get_vhost_versions", fixture(t, "php_vhost_versions.json")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 2 { + t.Fatalf("got %d vhosts, want 2", len(data)) + } + if data[0].Vhost != "main.example" { + t.Errorf("[0] vhost = %q", data[0].Vhost) + } + if data[0].Version != "ea-php81" { + t.Errorf("[0] version = %q", data[0].Version) + } + if data[0].MainDomain != 1 { + t.Errorf("[0] main_domain = %d, want 1", data[0].MainDomain) + } + if data[1].Version != "ea-php74" { + t.Errorf("[1] version = %q", data[1].Version) + } +} + +func TestParseListPHPVersionsEmpty(t *testing.T) { + empty := []byte(`{"result":{"data":[],"errors":null,"messages":null,"status":1}}`) + data, err := parseUAPI[[]PHPVhostEntry]("LangPHP", "php_get_vhost_versions", empty) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 0 { + t.Errorf("got %d, want 0", len(data)) + } +} diff --git a/internal/cpanel/runner_api_test.go b/internal/cpanel/runner_api_test.go index ab5f91f2..7dc3b1b4 100644 --- a/internal/cpanel/runner_api_test.go +++ b/internal/cpanel/runner_api_test.go @@ -93,6 +93,48 @@ func TestRunUAPIPropagatesSSHError(t *testing.T) { } } +// TestEncodeUAPIArgValue pins the encoder that makes a value survive cpsrvd's +// form-url-decoding of uapi CLI arguments (it decodes '+' -> space and +// '%XX' -> byte). Only '%' and '+' are rewritten; everything else is verbatim, +// so any value without those two bytes is unchanged (no regression for existing +// calls). Empirically verified on a live cPanel: a raw '+' in a TXT/DKIM value +// is stored as a space; '%2B' is stored as '+'. +func TestEncodeUAPIArgValue(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {"plain", "plain"}, // no special bytes -> unchanged + {"cpsm_x", "cpsm_x"}, // token name -> unchanged + {"a+b", "a%2Bb"}, // '+' -> %2B (else server stores a space) + {"50%off", "50%25off"}, // '%' -> %25 (else server may decode %XX) + {"a+b%c", "a%2Bb%25c"}, // '%' encoded first, then '+' + {"x&y a/b k=v", "x&y a/b k=v"}, // &, space, /, = all preserved verbatim + {"v=DKIM1; p=AB+CD/EF==", "v=DKIM1; p=AB%2BCD/EF=="}, // only '+' touched + {"%2B", "%252B"}, // already-encoded input is escaped again (pin: single call site, never double-applied) + {"pw%end", "pw%25end"}, // literal '%' mid-string + {"100%", "100%25"}, // trailing '%' + } + for _, c := range cases { + if got := encodeUAPIArgValue(c.in); got != c.want { + t.Errorf("encodeUAPIArgValue(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestUAPIArgValuePlusEncoded is the integration guard: the value a caller +// hands to RunUAPI must reach the env pre-encoded, so the '+' in a DKIM/base64 +// TXT payload is not silently turned into a space by the server. +func TestUAPIArgValuePlusEncoded(t *testing.T) { + f := &fakeRunner{out: uapiOK(`{"new_serial":"1"}`)} + dkim := `{"dname":"default._domainkey","record_type":"TXT","data":["v=DKIM1; p=MIIB+A/z=="]}` + _, _ = RunUAPI[json.RawMessage](bg, f, "DNS", "mass_edit_zone", map[string]string{"add-0": dkim}) + if f.env["ARG_0"] != encodeUAPIArgValue(dkim) { + t.Errorf("ARG_0 = %q, want the +/%%-encoded form %q", f.env["ARG_0"], encodeUAPIArgValue(dkim)) + } + if strings.Contains(f.env["ARG_0"], "+A/") { + t.Errorf("raw '+' reached the env (would be stored as a space): %q", f.env["ARG_0"]) + } +} + func TestParseUAPIStatusAndJSONErrors(t *testing.T) { // status != 1 -> error including the reported messages. if _, err := parseUAPI[json.RawMessage]("M", "f", uapiFail("boom")); err == nil || !strings.Contains(err.Error(), "boom") { diff --git a/internal/cpanel/ssl.go b/internal/cpanel/ssl.go new file mode 100644 index 00000000..4037bd9d --- /dev/null +++ b/internal/cpanel/ssl.go @@ -0,0 +1,34 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "context" + "sort" + + "github.com/tis24dev/cPanel_self-migration/internal/logx" +) + +type SSLCertEntry struct { + ID string `json:"id"` + FriendlyName string `json:"friendly_name"` + Domains flexStringList `json:"domains"` // string OR array (SAN list) across builds + IssuerCN string `json:"issuer.commonName"` + IssuerOrg string `json:"issuer.organizationName"` + NotBefore flexInt64 `json:"not_before"` // string OR number across builds + NotAfter flexInt64 `json:"not_after"` // string OR number across builds + IsSelfSigned flexInt64 `json:"is_self_signed"` // string OR number across builds + ValidationType string `json:"validation_type"` + ModulusLength flexInt64 `json:"modulus_length"` // string OR number across builds +} + +func ListSSLCerts(ctx context.Context, c Runner) ([]SSLCertEntry, error) { + data, err := RunUAPI[[]SSLCertEntry](ctx, c, "SSL", "list_certs", nil) + if err != nil { + return nil, err + } + sort.SliceStable(data, func(i, j int) bool { return data[i].FriendlyName < data[j].FriendlyName }) + logx.Debug("ListSSLCerts: %d cert(s)", len(data)) + return data, nil +} diff --git a/internal/cpanel/ssl_test.go b/internal/cpanel/ssl_test.go new file mode 100644 index 00000000..f898da58 --- /dev/null +++ b/internal/cpanel/ssl_test.go @@ -0,0 +1,70 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package cpanel + +import ( + "strings" + "testing" +) + +func TestParseListSSLCerts(t *testing.T) { + data, err := parseUAPI[[]SSLCertEntry]("SSL", "list_certs", fixture(t, "ssl_list_certs.json")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 2 { + t.Fatalf("got %d certs, want 2", len(data)) + } + if data[0].ID != "cert_abc123" { + t.Errorf("[0] id = %q", data[0].ID) + } + if !strings.Contains(string(data[0].Domains), "main.example") { + t.Errorf("[0] domains = %q, want to contain main.example", data[0].Domains) + } + if data[0].IssuerCN != "R3" { + t.Errorf("[0] issuer = %q", data[0].IssuerCN) + } + if data[0].NotAfter == 0 { + t.Error("[0] not_after is zero") + } +} + +func TestParseSSLCertsStringNumerics(t *testing.T) { + // Real cPanel builds return the numeric cert fields as STRINGS; flexInt64 + // must accept both string and number. A plain int64/int struct rejected the + // string form, leaving the SSL section silently empty on a real host. + raw := []byte(`{"result":{"status":1,"data":[{"id":"c1","friendly_name":"f","domains":"a.example","not_before":"1781184888","not_after":"1788960887","is_self_signed":"0","modulus_length":"2048"}]}}`) + data, err := parseUAPI[[]SSLCertEntry]("SSL", "list_certs", raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 1 { + t.Fatalf("got %d, want 1", len(data)) + } + c := data[0] + if c.NotBefore != 1781184888 || c.NotAfter != 1788960887 || c.IsSelfSigned != 0 || c.ModulusLength != 2048 { + t.Errorf("string numerics not parsed: nb=%d na=%d ss=%d ml=%d", int64(c.NotBefore), int64(c.NotAfter), int64(c.IsSelfSigned), int64(c.ModulusLength)) + } +} + +func TestParseListSSLCertsEmpty(t *testing.T) { + empty := []byte(`{"result":{"data":[],"errors":null,"messages":null,"status":1}}`) + data, err := parseUAPI[[]SSLCertEntry]("SSL", "list_certs", empty) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(data) != 0 { + t.Errorf("got %d, want 0", len(data)) + } +} + +func TestSSLCertNoPrivateKey(t *testing.T) { + raw := fixture(t, "ssl_list_certs.json") + s := strings.ToLower(string(raw)) + for _, bad := range []string{"private", "key_pem", "BEGIN RSA"} { + if strings.Contains(s, strings.ToLower(bad)) { + t.Errorf("fixture contains private key material: %q", bad) + } + } +} diff --git a/internal/cpanel/types.go b/internal/cpanel/types.go index 7e9045f5..0da8a280 100644 --- a/internal/cpanel/types.go +++ b/internal/cpanel/types.go @@ -28,12 +28,47 @@ func (f *flexInt64) UnmarshalJSON(b []byte) error { } if n, err := strconv.ParseInt(s, 10, 64); err == nil { *f = flexInt64(n) + return nil + } + // Some builds report a fractional value (e.g. Ftp::list_ftp_with_disk's + // diskused = "57632.08" or a bare 13558.40). Truncate to the integer part + // rather than collapse to 0, which would zero out every disk figure. + if v, err := strconv.ParseFloat(s, 64); err == nil { + *f = flexInt64(int64(v)) + return nil } - // A non-integer value (e.g. "" handled above, or unexpected text) stays 0: + // A non-numeric value (e.g. "" handled above, or unexpected text) stays 0: // this field is informational only, so we never fail the surrounding decode. return nil } +// flexStringList decodes a field that a cPanel build returns as EITHER a +// single string or a JSON array of strings (notably SSL::list_certs' domains, +// a SAN list). Values are flattened to a comma-joined string, the form the +// diff/policy layers already key on, so a shape change can never fail the +// whole response and silently drop the section. +type flexStringList string + +func (f *flexStringList) UnmarshalJSON(b []byte) error { + s := strings.TrimSpace(string(b)) + if s == "" || s == "null" { + return nil + } + if s[0] == '[' { + var arr []string + if err := json.Unmarshal(b, &arr); err != nil { + return nil // never fail the surrounding decode over a cosmetic field + } + *f = flexStringList(strings.Join(arr, ",")) + return nil + } + var one string + if err := json.Unmarshal(b, &one); err == nil { + *f = flexStringList(one) + } + return nil +} + // envelope is the standard UAPI result wrapper: // // {"result":{"data":...,"errors":[...],"messages":[...],"status":1}} @@ -129,6 +164,19 @@ func (d CreateTokenData) hasUnboundExpiry() bool { return d.ExpiresAt == 0 && (d.Expires != 0 || d.Expiry != 0) } +// api2Envelope is the generic API2 response wrapper for cpapi2 CLI calls: +// +// {"cpanelresult":{"data":...,"event":{"result":1},"error":"..."}} +type api2Envelope[T any] struct { + CPanelResult struct { + Data T `json:"data"` + Event struct { + Result json.Number `json:"result"` + } `json:"event"` + Error string `json:"error"` + } `json:"cpanelresult"` +} + // api2Response is the legacy api2 wrapper used by AddonDomain::addaddondomain: // // {"cpanelresult":{"data":[{"result":1,"reason":"..."}], "event":{"result":1}}} diff --git a/internal/cpanel/types_test.go b/internal/cpanel/types_test.go index dc249da6..c50287e5 100644 --- a/internal/cpanel/types_test.go +++ b/internal/cpanel/types_test.go @@ -50,7 +50,7 @@ func TestDatabaseEntryDiskUsageNumberOrString(t *testing.T) { ]` var got []DatabaseEntry if err := json.Unmarshal([]byte(in), &got); err != nil { - t.Fatalf("unmarshal failed (the bug — a string disk_usage aborted the list): %v", err) + t.Fatalf("unmarshal failed (the bug, a string disk_usage aborted the list): %v", err) } want := []struct { db string @@ -70,3 +70,30 @@ func TestDatabaseEntryDiskUsageNumberOrString(t *testing.T) { } } } + +// flexInt64 must truncate a float / float-string to its integer part rather +// than silently collapsing to 0 (which would zero out every FTP disk figure). +func TestFlexInt64AcceptsFloats(t *testing.T) { + cases := []struct { + raw string + want int64 + }{ + {`123`, 123}, + {`"123"`, 123}, + {`57632.08`, 57632}, + {`"57632.08"`, 57632}, + {`13558.40`, 13558}, + {`null`, 0}, + {`""`, 0}, + {`"abc"`, 0}, + } + for _, c := range cases { + var f flexInt64 + if err := f.UnmarshalJSON([]byte(c.raw)); err != nil { + t.Errorf("UnmarshalJSON(%s) error: %v", c.raw, err) + } + if int64(f) != c.want { + t.Errorf("flexInt64(%s) = %d, want %d", c.raw, int64(f), c.want) + } + } +} diff --git a/internal/dbmig/cmsrewrite.go b/internal/dbmig/cmsrewrite.go index 38d03f1c..0748fea7 100644 --- a/internal/dbmig/cmsrewrite.go +++ b/internal/dbmig/cmsrewrite.go @@ -106,7 +106,7 @@ func arrayKeyPre(key string) string { func replaceArrayKeyIn(block, key, value string) string { out, ok := replaceQuotedAfter(block, arrayKeyPre(key), value) if !ok { - logx.Debug("replaceArrayKeyIn: key %q not found in the bounded block (%d bytes); left unwritten (fail-closed) — read-after-write verify will surface it", key, len(block)) + logx.Debug("replaceArrayKeyIn: key %q not found in the bounded block (%d bytes); left unwritten (fail-closed), read-after-write verify will surface it", key, len(block)) } return out } diff --git a/internal/dbmig/deepverify_faultsim_test.go b/internal/dbmig/deepverify_faultsim_test.go index af0793e9..be7ff5cd 100644 --- a/internal/dbmig/deepverify_faultsim_test.go +++ b/internal/dbmig/deepverify_faultsim_test.go @@ -87,7 +87,7 @@ func TestFaultSimParseMetaFailsClosedOnBadHex(t *testing.T) { func TestFaultSimParseMetaEmptyVersionDownstreamSafe(t *testing.T) { version, _, _, ok := parseMeta("V\t\t") if !ok || version != "" { - t.Errorf("parseMeta(\"V\\t\\t\") = (version=%q, ok=%v), want (\"\", true) — pinned behavior", version, ok) + t.Errorf("parseMeta(\"V\\t\\t\") = (version=%q, ok=%v), want (\"\", true), pinned behavior", version, ok) } } diff --git a/internal/dbmig/discover.go b/internal/dbmig/discover.go index 92d1265d..80b8ffb4 100644 --- a/internal/dbmig/discover.go +++ b/internal/dbmig/discover.go @@ -116,7 +116,7 @@ func DiscoverAllCreds(ctx context.Context, src Runner, docroots, wantDBs []strin } // 1) Softaculous (best, CMS-agnostic source). - logx.Debug("DiscoverAllCreds: phase 1 — scanning softaculous registry") + logx.Debug("DiscoverAllCreds: phase 1: scanning softaculous registry") soft, err := DiscoverSoftaculous(ctx, src) if err != nil { return nil, err @@ -131,7 +131,7 @@ func DiscoverAllCreds(ctx context.Context, src Runner, docroots, wantDBs []strin } all = append(all, cfg...) note(cfg) - logx.Debug("DiscoverAllCreds: site configs phase complete — %d rewrite target(s) found", len(cfg)) + logx.Debug("DiscoverAllCreds: site configs phase complete, %d rewrite target(s) found", len(cfg)) // 3) Name-search for databases still missing a password after 1+2. var missing []string @@ -141,7 +141,7 @@ func DiscoverAllCreds(ctx context.Context, src Runner, docroots, wantDBs []strin } } if len(missing) > 0 { - logx.Debug("DiscoverAllCreds: phase 3 — %d database(s) still missing passwords, attempting name-search grep", len(missing)) + logx.Debug("DiscoverAllCreds: phase 3: %d database(s) still missing passwords, attempting name-search grep", len(missing)) found, err := SearchCredsByDBName(ctx, src, docroots, missing) if err != nil { return nil, err @@ -215,7 +215,7 @@ func SearchCredsByDBName(ctx context.Context, src Runner, docroots []string, dbN logx.Debug("SearchCredsByDBName: %s -> %s (%s)", db, path, creds.String()) break } - logx.Debug("SearchCredsByDBName: %s mentioned in %s but the parse did not yield matching creds (got db=%q user-present=%v) — trying next candidate", db, path, creds.DBName, creds.DBUser != "") + logx.Debug("SearchCredsByDBName: %s mentioned in %s but the parse did not yield matching creds (got db=%q user-present=%v), trying next candidate", db, path, creds.DBName, creds.DBUser != "") } } return out, nil diff --git a/internal/dbmig/gtid_purged_test.go b/internal/dbmig/gtid_purged_test.go index 6e1500b4..b25ede94 100644 --- a/internal/dbmig/gtid_purged_test.go +++ b/internal/dbmig/gtid_purged_test.go @@ -120,7 +120,7 @@ func TestSrcSupportsGtidPurgedProbeErrors(t *testing.T) { t.Fatalf("fake mysqldump never ran: %v", e) } if err == nil { - t.Fatalf("probe must ERROR when mysqldump itself fails (got ok=%v) — a silent false "+ + t.Fatalf("probe must ERROR when mysqldump itself fails (got ok=%v), a silent false "+ "would omit the flag on a real MySQL source and break the import later", ok) } } @@ -143,7 +143,7 @@ func TestCopyDatabaseGtidOffSuppressesGtidPurged(t *testing.T) { t.Fatalf("dest mysql never captured a stream: %v", err) } if strings.Contains(string(got), "GTID_PURGED") { - t.Errorf("the stream that reached the dest still carries GTID_PURGED — the flag was not applied:\n%s", got) + t.Errorf("the stream that reached the dest still carries GTID_PURGED, the flag was not applied:\n%s", got) } if !strings.Contains(string(got), "gtid-suppressed") { t.Errorf("expected the flag-honored marker in the dest stream, got:\n%s", got) @@ -174,7 +174,7 @@ func TestCopyDatabaseMariaDBFlagGating(t *testing.T) { trBad := Transfer{Src: dialSrc(t), Dest: dialSrc(t), Timeout: 10 * time.Second, DumpCmd: BuildDumpCmd(true)} _, err := trBad.CopyDatabase(bg, DBPlanItem{SrcDB: "db", DestDB: "vh_db"}, "du", "dp", nil) if err == nil { - t.Fatal("MariaDB copy WITH --set-gtid-purged must fail (unknown option) — proving the flag must be version-gated") + t.Fatal("MariaDB copy WITH --set-gtid-purged must fail (unknown option), proving the flag must be version-gated") } if se := sshx.SideError(err, sshx.SideSource); se == nil { t.Errorf("the MariaDB dump failure must be tagged SideSource, got: %v", err) diff --git a/internal/dbmig/import_pipefail_test.go b/internal/dbmig/import_pipefail_test.go index de9a345f..1a7d37b6 100644 --- a/internal/dbmig/import_pipefail_test.go +++ b/internal/dbmig/import_pipefail_test.go @@ -103,7 +103,7 @@ func TestImportPipelineSurfacesSedFailure(t *testing.T) { t.Fatalf("fake sed never ran (PATH not honored on dest): %v", e) } if err == nil { - t.Fatal("CopyDatabase reported SUCCESS while sed (first pipeline stage) exited non-zero — " + + t.Fatal("CopyDatabase reported SUCCESS while sed (first pipeline stage) exited non-zero, " + "the import is not running under `set -o pipefail` (a partial import would pass silently)") } if se := sshx.SideError(err, sshx.SideDest); se == nil { diff --git a/internal/dbmig/plan.go b/internal/dbmig/plan.go index cd2dce15..1b774917 100644 --- a/internal/dbmig/plan.go +++ b/internal/dbmig/plan.go @@ -231,7 +231,7 @@ func BuildPlanWithMapping(dbs []cpanel.DatabaseEntry, users []cpanel.DBUserEntry // Warn (not Debug): this is a GUESS for the DB owner; if it is wrong the // grant step fails later with an error that does not mention the guess, so // surface the high-risk fallback at info level for correlation. - logx.Warn("database %s: owner unknown — guessing the same-named user (high risk; the grant may fail if this is wrong)", db.Database) + logx.Warn("database %s: owner unknown, guessing the same-named user (high risk; the grant may fail if this is wrong)", db.Database) } password := passwordByDB[db.Database] diff --git a/internal/dbmig/rewrite_dispatch_test.go b/internal/dbmig/rewrite_dispatch_test.go index 5c36dfa1..0db38a72 100644 --- a/internal/dbmig/rewrite_dispatch_test.go +++ b/internal/dbmig/rewrite_dispatch_test.go @@ -18,7 +18,7 @@ import ( func TestRewriteSiteConfigUnsupportedKind(t *testing.T) { for _, kind := range []Kind{KindCubeCart, KindMatomo, KindLimeSurvey, KindUnknown} { if _, supported := siteRewriters[kind]; supported { - t.Fatalf("test bug: %q is now supported — update this list", kind) + t.Fatalf("test bug: %q is now supported, update this list", kind) } err := RewriteSiteConfig(context.Background(), nil, "/dest/sites/default/settings.php", kind, "vh_db", "vh_user", "pw") var ue *UnsupportedRewriteError diff --git a/internal/dbmig/softaculous_test.go b/internal/dbmig/softaculous_test.go index 25e49238..66289479 100644 --- a/internal/dbmig/softaculous_test.go +++ b/internal/dbmig/softaculous_test.go @@ -63,7 +63,7 @@ func TestParseSoftaculousExtractsCreds(t *testing.T) { // credentials here. orphan, ok := credForDB(creds, "srcacct_wp590") if !ok { - t.Fatal("wp590 not found — Softaculous should recover its creds") + t.Fatal("wp590 not found, Softaculous should recover its creds") } if orphan.DBPassword != "upIb932)S!" { t.Errorf("wp590 password = %q, want upIb932)S!", orphan.DBPassword) diff --git a/internal/dbmig/staleconfig.go b/internal/dbmig/staleconfig.go index a9eaa34b..b3003e2a 100644 --- a/internal/dbmig/staleconfig.go +++ b/internal/dbmig/staleconfig.go @@ -126,7 +126,7 @@ func SourceCredsStillReachable(ctx context.Context, dest Runner, docroot, srcDB, if isNonLiveConfigPath(p) { continue // a backup/old copy or an error log, never the live config the site loads } - return true, fmt.Sprintf("source %s %q is still reachable in %s after the rewrite — the live DB value may be set there (a split/included config, a Laravel config cache, or a second un-rewritten definition), not where the cutover wrote it", n.kind, n.val, p), nil + return true, fmt.Sprintf("source %s %q is still reachable in %s after the rewrite, the live DB value may be set there (a split/included config, a Laravel config cache, or a second un-rewritten definition), not where the cutover wrote it", n.kind, n.val, p), nil } } return false, "", nil diff --git a/internal/dbmig/transfer.go b/internal/dbmig/transfer.go index 2c19a2c9..a5c14665 100644 --- a/internal/dbmig/transfer.go +++ b/internal/dbmig/transfer.go @@ -85,10 +85,10 @@ func (t Transfer) CopyDatabase(ctx context.Context, item DBPlanItem, destUser, d return sshx.BridgeProgress(bctx, t.Src, dump, srcEnv, nil, t.Dest, importCmd, destEnv, wrapped) }) if err != nil { - logx.Debug("CopyDatabase %s: bridge failed after %d bytes — likely schema/permission issue", item.SrcDB, sent) + logx.Debug("CopyDatabase %s: bridge failed after %d bytes, likely schema/permission issue", item.SrcDB, sent) return CopyResult{BytesSent: sent}, fmt.Errorf("%s: dump bridge: %w", item.SrcDB, err) } - logx.Debug("CopyDatabase %s: complete — %d bytes transferred", item.SrcDB, sent) + logx.Debug("CopyDatabase %s: complete, %d bytes transferred", item.SrcDB, sent) return CopyResult{BytesSent: sent}, nil } diff --git a/internal/events/event.go b/internal/events/event.go new file mode 100644 index 00000000..729c53ff --- /dev/null +++ b/internal/events/event.go @@ -0,0 +1,97 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// Package events provides structured event emission for migration runs. +// Events are written as JSONL (one JSON object per line) to a file, +// without replacing the existing human-readable stdout output. +package events + +import ( + "fmt" + "strings" + "time" +) + +type Level string + +const ( + LevelInfo Level = "info" + LevelWarn Level = "warn" + LevelError Level = "error" +) + +type Phase string + +const ( + PhaseConnect Phase = "connect" + PhaseAnalyzeMail Phase = "analyze_mail" + PhaseAnalyzeFiles Phase = "analyze_files" + PhaseAnalyzeDB Phase = "analyze_db" + PhaseGatherData Phase = "gather_data" + PhaseCompareMail Phase = "compare_mail" + PhaseCompareFiles Phase = "compare_files" + PhaseCompareDB Phase = "compare_db" + PhaseCreateDomains Phase = "create_domains" + PhaseMigrateMail Phase = "migrate_mail" + PhaseVerifyMail Phase = "verify_mail" + PhaseCopyFiles Phase = "copy_files" + PhaseVerifyFiles Phase = "verify_files" + PhaseMigrateDB Phase = "migrate_db" + PhaseVerifyDB Phase = "verify_db" +) + +type EventType string + +const ( + EventPhaseStarted EventType = "phase_started" + EventPhaseCompleted EventType = "phase_completed" + EventPhaseSkipped EventType = "phase_skipped" + EventPhaseFailed EventType = "phase_failed" + EventRunStarted EventType = "run_started" + EventRunCompleted EventType = "run_completed" + EventRunFailed EventType = "run_failed" +) + +type HostRef struct { + IP string `json:"ip"` + User string `json:"user"` +} + +type Event struct { + RunID string `json:"run_id"` + TS time.Time `json:"ts"` + Level Level `json:"level"` + Phase Phase `json:"phase"` + Type EventType `json:"event"` + Message string `json:"message"` + Source HostRef `json:"source,omitempty"` + Dest HostRef `json:"destination,omitempty"` + Data any `json:"data,omitempty"` +} + +type Emitter struct { + Emit func(Event) +} + +func (e Emitter) Send(ev Event) { + if e.Emit != nil { + e.Emit(ev) + } +} + +func NewRunID(t time.Time) string { + return t.Format("run-20060102-150405") +} + +func ValidateRunID(id string) error { + if id == "" { + return fmt.Errorf("run-id must not be empty") + } + if len(id) > 128 { + return fmt.Errorf("run-id must not exceed 128 characters") + } + if strings.ContainsAny(id, "/\\\x00") { + return fmt.Errorf("run-id must not contain slashes or null bytes") + } + return nil +} diff --git a/internal/events/event_test.go b/internal/events/event_test.go new file mode 100644 index 00000000..8184cd95 --- /dev/null +++ b/internal/events/event_test.go @@ -0,0 +1,186 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package events + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestNewRunID(t *testing.T) { + ts := time.Date(2026, 7, 1, 15, 30, 0, 0, time.UTC) + got := NewRunID(ts) + if got != "run-20260701-153000" { + t.Errorf("NewRunID(%v) = %q, want %q", ts, got, "run-20260701-153000") + } +} + +func TestValidateRunID(t *testing.T) { + tests := []struct { + name string + id string + wantErr bool + }{ + {name: "valid generated", id: "run-20260701-153000"}, + {name: "valid custom", id: "my-migration-2026"}, + {name: "valid alphanumeric", id: "abc123"}, + {name: "valid with dashes", id: "a-b-c"}, + {name: "valid with underscores", id: "a_b_c"}, + {name: "valid with dots", id: "run.1.2"}, + {name: "empty", id: "", wantErr: true}, + {name: "contains slash", id: "a/b", wantErr: true}, + {name: "contains backslash", id: `a\b`, wantErr: true}, + {name: "contains null", id: "a\x00b", wantErr: true}, + {name: "too long", id: strings.Repeat("a", 129), wantErr: true}, + {name: "max length", id: strings.Repeat("a", 128)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateRunID(tt.id) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateRunID(%q) error = %v, wantErr %v", tt.id, err, tt.wantErr) + } + }) + } +} + +func TestEventJSON(t *testing.T) { + ev := Event{ + RunID: "run-20260701-153000", + TS: time.Date(2026, 7, 1, 15, 30, 0, 123000000, time.UTC), + Level: LevelInfo, + Phase: PhaseConnect, + Type: EventPhaseStarted, + Message: "Connecting to source and destination", + Source: HostRef{IP: "1.2.3.4", User: "srcuser"}, + Dest: HostRef{IP: "5.6.7.8", User: "destuser"}, + } + b, err := json.Marshal(ev) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + s := string(b) + + // Must contain all required fields. + for _, want := range []string{ + `"run_id":"run-20260701-153000"`, + `"level":"info"`, + `"phase":"connect"`, + `"event":"phase_started"`, + `"source":{"ip":"1.2.3.4","user":"srcuser"}`, + `"destination":{"ip":"5.6.7.8","user":"destuser"}`, + } { + if !strings.Contains(s, want) { + t.Errorf("JSON missing %q in %s", want, s) + } + } + // Must NOT contain passwords, tokens, or other secrets. + for _, bad := range []string{"password", "token", "secret", "key"} { + if strings.Contains(strings.ToLower(s), bad) { + t.Errorf("JSON contains sensitive keyword %q: %s", bad, s) + } + } +} + +func TestEventJSONRoundTrip(t *testing.T) { + ev := Event{ + RunID: "test-run", + TS: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + Level: LevelWarn, + Phase: PhaseAnalyzeMail, + Type: EventPhaseCompleted, + Message: "done", + Source: HostRef{IP: "10.0.0.1", User: "u1"}, + Dest: HostRef{IP: "10.0.0.2", User: "u2"}, + Data: map[string]any{"mailboxes": 42}, + } + b, err := json.Marshal(ev) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var got Event + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.RunID != ev.RunID { + t.Errorf("RunID: got %q, want %q", got.RunID, ev.RunID) + } + if got.Level != ev.Level { + t.Errorf("Level: got %q, want %q", got.Level, ev.Level) + } + if got.Phase != ev.Phase { + t.Errorf("Phase: got %q, want %q", got.Phase, ev.Phase) + } + if got.Type != ev.Type { + t.Errorf("Type: got %q, want %q", got.Type, ev.Type) + } +} + +func TestPhaseConstants(t *testing.T) { + phases := []Phase{ + PhaseConnect, PhaseAnalyzeMail, PhaseAnalyzeFiles, + PhaseAnalyzeDB, PhaseGatherData, PhaseCompareMail, + PhaseCompareFiles, PhaseCompareDB, PhaseCreateDomains, + PhaseMigrateMail, PhaseVerifyMail, PhaseCopyFiles, + PhaseVerifyFiles, PhaseMigrateDB, PhaseVerifyDB, + } + seen := make(map[Phase]bool) + for _, p := range phases { + if p == "" { + t.Error("empty phase constant") + } + if seen[p] { + t.Errorf("duplicate phase %q", p) + } + seen[p] = true + } +} + +func TestLevelConstants(t *testing.T) { + levels := []Level{LevelInfo, LevelWarn, LevelError} + seen := make(map[Level]bool) + for _, l := range levels { + if l == "" { + t.Error("empty level constant") + } + if seen[l] { + t.Errorf("duplicate level %q", l) + } + seen[l] = true + } +} + +func TestEventTypeConstants(t *testing.T) { + types := []EventType{ + EventPhaseStarted, EventPhaseCompleted, EventPhaseSkipped, + EventPhaseFailed, EventRunStarted, EventRunCompleted, EventRunFailed, + } + seen := make(map[EventType]bool) + for _, et := range types { + if et == "" { + t.Error("empty event type constant") + } + if seen[et] { + t.Errorf("duplicate event type %q", et) + } + seen[et] = true + } +} + +func TestEmitterNilSafe(t *testing.T) { + var em Emitter + // Must not panic with nil Emit. + em.Send(Event{Phase: PhaseConnect, Type: EventPhaseStarted}) +} + +func TestEmitterCallsFunc(t *testing.T) { + var called bool + em := Emitter{Emit: func(e Event) { called = true }} + em.Send(Event{Phase: PhaseConnect, Type: EventPhaseStarted}) + if !called { + t.Error("Emitter.Send did not call Emit func") + } +} diff --git a/internal/events/redact.go b/internal/events/redact.go new file mode 100644 index 00000000..84dd1433 --- /dev/null +++ b/internal/events/redact.go @@ -0,0 +1,34 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package events + +import ( + "bytes" + "encoding/json" + + "github.com/tis24dev/cPanel_self-migration/internal/redact" +) + +// RedactMap delegates to the shared internal/redact choke-point. Kept as an +// events-level surface because writer.go (redactData) routes every event +// payload through it. +func RedactMap(m map[string]any) map[string]any { + return redact.RedactMap(m) +} + +// jsonMarshal encodes v with HTML escaping off and no trailing newline. Local +// to events (writer.go and the test mapToString use it); unrelated to redaction. +func jsonMarshal(v any) ([]byte, error) { + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + b := buf.Bytes() + if len(b) > 0 && b[len(b)-1] == '\n' { + b = b[:len(b)-1] + } + return b, nil +} diff --git a/internal/events/redact_test.go b/internal/events/redact_test.go new file mode 100644 index 00000000..23160d3d --- /dev/null +++ b/internal/events/redact_test.go @@ -0,0 +1,124 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package events + +import ( + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/redact" +) + +func TestRedactMap(t *testing.T) { + tests := []struct { + name string + input map[string]any + contains []string + excludes []string + }{ + { + name: "password redacted", + input: map[string]any{"password": "secret123", "user": "admin"}, + contains: []string{"", "admin"}, + excludes: []string{"secret123"}, + }, + { + name: "token redacted", + input: map[string]any{"api_token": "tok_abc", "ip": "1.2.3.4"}, + contains: []string{"", "1.2.3.4"}, + excludes: []string{"tok_abc"}, + }, + { + name: "nested password", + input: map[string]any{"config": map[string]any{"db_password": "pw123"}}, + contains: []string{""}, + excludes: []string{"pw123"}, + }, + { + name: "key containing secret", + input: map[string]any{"client_secret": "s3cr3t"}, + contains: []string{""}, + excludes: []string{"s3cr3t"}, + }, + { + name: "auth header", + input: map[string]any{"authorization": "Bearer xyz"}, + contains: []string{""}, + excludes: []string{"Bearer xyz"}, + }, + { + name: "credential key", + input: map[string]any{"credentials": "user:pass"}, + contains: []string{""}, + excludes: []string{"user:pass"}, + }, + { + name: "safe keys unchanged", + input: map[string]any{"ip": "1.2.3.4", "user": "admin", "domain": "example.com"}, + contains: []string{"1.2.3.4", "admin", "example.com"}, + }, + { + name: "empty password preserved", + input: map[string]any{"password": ""}, + contains: []string{`"password":""`}, + }, + { + name: "nil password preserved", + input: map[string]any{"password": nil}, + contains: []string{`"password":null`}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RedactMap(tt.input) + s := mapToString(got) + for _, want := range tt.contains { + if !strings.Contains(s, want) { + t.Errorf("result missing %q: %s", want, s) + } + } + for _, bad := range tt.excludes { + if strings.Contains(s, bad) { + t.Errorf("result contains secret %q: %s", bad, s) + } + } + }) + } +} + +func TestIsSensitiveKey(t *testing.T) { + sensitive := []string{ + "password", "Password", "DB_PASSWORD", + "token", "api_token", "cpanel_token", + "secret", "client_secret", + "ssh_pass", "passphrase", + "auth", "authorization", + "key", "api_key", "private_key", + "credential", "credentials", + "cookie", "session_cookie", + "session", "session_id", + "bearer", + } + for _, k := range sensitive { + if !redact.IsSensitiveKey(k) { + t.Errorf("IsSensitiveKey(%q) = false, want true", k) + } + } + + safe := []string{ + "ip", "user", "domain", "port", "host", + "mailbox", "database", "docroot", "path", + "count", "size", "status", "version", + } + for _, k := range safe { + if redact.IsSensitiveKey(k) { + t.Errorf("IsSensitiveKey(%q) = true, want false", k) + } + } +} + +func mapToString(m map[string]any) string { + b, _ := jsonMarshal(m) + return string(b) +} diff --git a/internal/events/report.go b/internal/events/report.go new file mode 100644 index 00000000..f725b7b1 --- /dev/null +++ b/internal/events/report.go @@ -0,0 +1,60 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package events + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +type ExitStatus string + +const ( + ExitSuccess ExitStatus = "success" + ExitFailed ExitStatus = "failed" + ExitInterrupted ExitStatus = "interrupted" +) + +type ReportScope struct { + Mail bool `json:"mail"` + Files bool `json:"files"` + Databases bool `json:"databases"` + DomainFilter string `json:"domain_filter,omitempty"` + MailboxFilter string `json:"mailbox_filter,omitempty"` +} + +type RunReport struct { + RunID string `json:"run_id"` + Version string `json:"version"` + Mode string `json:"mode"` + Scope ReportScope `json:"scope"` + Source HostRef `json:"source"` + Dest HostRef `json:"destination"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + ExitStatus ExitStatus `json:"exit_status"` + PhasesCompleted []Phase `json:"phases_completed"` + Warnings []string `json:"warnings"` + Errors []string `json:"errors"` + Artifacts map[string]string `json:"artifacts,omitempty"` +} + +func WriteReport(path string, r RunReport) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("events: create directory %s: %w", dir, err) + } + b, err := json.MarshalIndent(r, "", " ") + if err != nil { + return fmt.Errorf("events: marshal report: %w", err) + } + b = append(b, '\n') + if err := os.WriteFile(path, b, 0o600); err != nil { + return fmt.Errorf("events: write %s: %w", path, err) + } + return nil +} diff --git a/internal/events/report_test.go b/internal/events/report_test.go new file mode 100644 index 00000000..d2ead36b --- /dev/null +++ b/internal/events/report_test.go @@ -0,0 +1,138 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package events + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRunReportJSON(t *testing.T) { + r := RunReport{ + RunID: "run-20260701-153000", + Version: "2.2.1", + Mode: "dry-run", + Scope: ReportScope{ + Mail: true, + Files: true, + Databases: true, + DomainFilter: "", + MailboxFilter: "", + }, + Source: HostRef{IP: "1.2.3.4", User: "srcuser"}, + Dest: HostRef{IP: "5.6.7.8", User: "destuser"}, + StartedAt: time.Date(2026, 7, 1, 15, 30, 0, 0, time.UTC), + FinishedAt: time.Date(2026, 7, 1, 15, 35, 0, 0, time.UTC), + ExitStatus: ExitSuccess, + PhasesCompleted: []Phase{PhaseConnect, PhaseAnalyzeMail}, + Warnings: []string{}, + Errors: []string{}, + } + + b, err := json.MarshalIndent(r, "", " ") + if err != nil { + t.Fatalf("Marshal: %v", err) + } + s := string(b) + + for _, want := range []string{ + `"run_id"`, `"version"`, `"mode"`, `"scope"`, + `"source"`, `"destination"`, `"started_at"`, `"finished_at"`, + `"exit_status"`, `"phases_completed"`, + } { + if !strings.Contains(s, want) { + t.Errorf("report missing field %s", want) + } + } +} + +func TestRunReportRoundTrip(t *testing.T) { + r := RunReport{ + RunID: "test", + Version: "1.0.0", + Mode: "apply", + ExitStatus: ExitFailed, + Errors: []string{"something went wrong"}, + } + b, err := json.Marshal(r) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + var got RunReport + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.RunID != r.RunID { + t.Errorf("RunID: got %q, want %q", got.RunID, r.RunID) + } + if got.ExitStatus != r.ExitStatus { + t.Errorf("ExitStatus: got %q, want %q", got.ExitStatus, r.ExitStatus) + } + if len(got.Errors) != 1 { + t.Fatalf("Errors: got %d, want 1", len(got.Errors)) + } +} + +func TestWriteReport(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "report.json") + r := RunReport{ + RunID: "test", + Version: "2.2.1", + Mode: "dry-run", + ExitStatus: ExitSuccess, + Source: HostRef{IP: "1.2.3.4", User: "u"}, + } + if err := WriteReport(path, r); err != nil { + t.Fatalf("WriteReport: %v", err) + } + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var got RunReport + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if got.RunID != "test" { + t.Errorf("RunID: got %q, want %q", got.RunID, "test") + } +} + +func TestExitStatusConstants(t *testing.T) { + statuses := []ExitStatus{ExitSuccess, ExitFailed, ExitInterrupted} + seen := make(map[ExitStatus]bool) + for _, s := range statuses { + if s == "" { + t.Error("empty exit status") + } + if seen[s] { + t.Errorf("duplicate exit status %q", s) + } + seen[s] = true + } +} + +func TestReportNoSecrets(t *testing.T) { + r := RunReport{ + RunID: "test", + Source: HostRef{IP: "1.2.3.4", User: "admin"}, + Dest: HostRef{IP: "5.6.7.8", User: "dest"}, + } + b, err := json.Marshal(r) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + s := strings.ToLower(string(b)) + for _, bad := range []string{"password", "ssh_pass", "token", "secret"} { + if strings.Contains(s, bad) { + t.Errorf("report contains %q: %s", bad, string(b)) + } + } +} diff --git a/internal/events/writer.go b/internal/events/writer.go new file mode 100644 index 00000000..d0e7a04c --- /dev/null +++ b/internal/events/writer.go @@ -0,0 +1,90 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package events + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" +) + +type Writer struct { + mu sync.Mutex + f *os.File + enc *json.Encoder + path string +} + +func NewWriter(path string) (*Writer, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("events: create directory %s: %w", dir, err) + } + root, err := os.OpenRoot(dir) + if err != nil { + return nil, fmt.Errorf("events: open output directory %s: %w", dir, err) + } + defer func() { _ = root.Close() }() + f, err := root.OpenFile(filepath.Base(path), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, fmt.Errorf("events: open %s: %w", path, err) + } + enc := json.NewEncoder(f) + enc.SetEscapeHTML(false) + return &Writer{f: f, enc: enc, path: path}, nil +} + +func (w *Writer) Write(ev Event) error { + w.mu.Lock() + defer w.mu.Unlock() + safe := ev + safe.Data = redactData(safe.Data) + if err := w.enc.Encode(safe); err != nil { + return fmt.Errorf("events: write: %w", err) + } + return nil +} + +// redactData routes ANY Data payload through the key-based redaction net, +// not just map[string]any: a typed struct payload (e.g. the apply phase +// events) is marshaled to its JSON object form and redacted as a map, so a +// future payload with a sensitive field name cannot silently bypass +// RedactMap. Non-object payloads (arrays, scalars) and payloads that fail +// to marshal pass through unchanged, redaction is KEY-based, so there is +// no key to match on them (a marshal failure surfaces identically at +// Encode time anyway). +func redactData(v any) any { + switch d := v.(type) { + case nil: + return nil + case map[string]any: + return RedactMap(d) + } + b, err := jsonMarshal(v) + if err != nil { + return v + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return v + } + return RedactMap(m) +} + +func (w *Writer) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.f == nil { + return nil + } + err := w.f.Close() + w.f = nil + return err +} + +func (w *Writer) Path() string { + return w.path +} diff --git a/internal/events/writer_test.go b/internal/events/writer_test.go new file mode 100644 index 00000000..31327fd7 --- /dev/null +++ b/internal/events/writer_test.go @@ -0,0 +1,249 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package events + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestWriterCreatesFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "events.jsonl") + w, err := NewWriter(path) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + ev := Event{ + RunID: "test", + TS: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + Level: LevelInfo, + Phase: PhaseConnect, + Type: EventPhaseStarted, + Message: "test event", + } + if err := w.Write(ev); err != nil { + t.Fatalf("Write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if len(b) == 0 { + t.Fatal("file is empty") + } + // Must be valid JSON. + var parsed Event + if err := json.Unmarshal(b[:len(b)-1], &parsed); err != nil { // trim trailing newline + t.Fatalf("invalid JSON: %v\nraw: %s", err, b) + } + if parsed.RunID != "test" { + t.Errorf("run_id = %q, want %q", parsed.RunID, "test") + } +} + +func TestWriterJSONL(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "events.jsonl") + w, err := NewWriter(path) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + for i := 0; i < 5; i++ { + ev := Event{ + RunID: "test", + TS: time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC), + Level: LevelInfo, + Phase: PhaseConnect, + Type: EventPhaseStarted, + Message: "event", + } + if err := w.Write(ev); err != nil { + t.Fatalf("Write[%d]: %v", i, err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + f, err := os.Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + count := 0 + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + var ev Event + if err := json.Unmarshal([]byte(line), &ev); err != nil { + t.Errorf("line %d: invalid JSON: %v", count, err) + } + count++ + } + if count != 5 { + t.Errorf("got %d events, want 5", count) + } +} + +func TestWriterNoSecretsInOutput(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "events.jsonl") + w, err := NewWriter(path) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + ev := Event{ + RunID: "test", + TS: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + Level: LevelInfo, + Phase: PhaseConnect, + Type: EventPhaseStarted, + Message: "connecting", + Source: HostRef{IP: "1.2.3.4", User: "myuser"}, + Dest: HostRef{IP: "5.6.7.8", User: "destuser"}, + } + if err := w.Write(ev); err != nil { + t.Fatalf("Write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + s := strings.ToLower(string(b)) + // HostRef must not have password/token fields. + for _, bad := range []string{"password", "ssh_pass", "cpanel_token", "secret"} { + if strings.Contains(s, bad) { + t.Errorf("output contains %q: %s", bad, b) + } + } +} + +func TestWriterRedactsDataSecrets(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "events.jsonl") + w, err := NewWriter(path) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + ev := Event{ + RunID: "test", + TS: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + Level: LevelInfo, + Phase: PhaseConnect, + Type: EventPhaseCompleted, + Message: "done", + Data: map[string]any{"password": "s3cr3t", "user": "admin"}, + } + if err := w.Write(ev); err != nil { + t.Fatalf("Write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + s := string(b) + if strings.Contains(s, "s3cr3t") { + t.Errorf("written event contains unredacted password: %s", s) + } + if !strings.Contains(s, "admin") { + t.Errorf("written event missing non-secret value 'admin': %s", s) + } + if !strings.Contains(s, "") { + t.Errorf("written event missing redaction placeholder: %s", s) + } +} + +// TestWriterRedactsStructDataSecrets pins the PR 7C hardening: a TYPED +// struct payload (like the apply phase event data) must go through the same +// key-based redaction net as a map[string]any, a sensitive field name in a +// future payload type cannot silently bypass RedactMap. +func TestWriterRedactsStructDataSecrets(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "events.jsonl") + w, err := NewWriter(path) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + type item struct { + Item string `json:"item"` + Status string `json:"status"` + Note string `json:"note,omitempty"` + } + type payload struct { + Items []item `json:"items"` + Token string `json:"token"` + } + ev := Event{ + RunID: "test", + TS: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + Level: LevelInfo, + Phase: PhaseMigrateMail, + Type: EventPhaseCompleted, + Message: "done", + Data: payload{ + Items: []item{{Item: "info@example.com", Status: "failed", Note: "account step: timeout"}}, + Token: "s3cr3t-token", + }, + } + if err := w.Write(ev); err != nil { + t.Fatalf("Write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + s := string(b) + if strings.Contains(s, "s3cr3t-token") { + t.Errorf("struct payload bypassed redaction, token leaked: %s", s) + } + if !strings.Contains(s, "") { + t.Errorf("written event missing redaction placeholder: %s", s) + } + for _, want := range []string{"info@example.com", "failed", "account step: timeout"} { + if !strings.Contains(s, want) { + t.Errorf("non-sensitive payload content %q lost in redaction: %s", want, s) + } + } +} + +func TestWriterCreatesParentDir(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "subdir", "events.jsonl") + w, err := NewWriter(path) + if err != nil { + t.Fatalf("NewWriter: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Error("file was not created") + } +} diff --git a/internal/maildir/ssh_integration_test.go b/internal/maildir/ssh_integration_test.go index c676b335..d96ad221 100644 --- a/internal/maildir/ssh_integration_test.go +++ b/internal/maildir/ssh_integration_test.go @@ -263,7 +263,7 @@ func TestGetMessageDigestsReportsProgress(t *testing.T) { t.Fatalf("digest map size = %d, want 4: %v", len(md), md) } if len(counts) == 0 { - t.Fatal("WithProgress callback never fired — the row would stay frozen during hashing") + t.Fatal("WithProgress callback never fired, the row would stay frozen during hashing") } if counts[0] != 1 { t.Errorf("first progress tick = %d, want 1", counts[0]) diff --git a/internal/maildir/transfer.go b/internal/maildir/transfer.go index 19d72d7e..db7b8cc0 100644 --- a/internal/maildir/transfer.go +++ b/internal/maildir/transfer.go @@ -121,7 +121,7 @@ func (t Transfer) SyncBoxProgressDomains(ctx context.Context, srcDom, destDom, u // nothing to copy. Returning an error here would mark a legitimately-empty // ACTIVE account FAILED whenever it reaches the copy: e.g. under --full (which // bypasses the fast-skip) or when the stats read that drives the skip hiccups. - logx.Debug("SyncBox %s/%s: source mailbox empty — nothing to copy", srcDom, user) + logx.Debug("SyncBox %s/%s: source mailbox empty, nothing to copy", srcDom, user) return SyncResult{}, nil } if t.afterScan != nil { @@ -205,7 +205,7 @@ func (t Transfer) SyncBoxProgressDomains(ctx context.Context, srcDom, destDom, u // Convergence failure (distinct from a generic batch error): the mailbox is // being written FASTER than it can be copied, so re-running --apply hits the // same wall. Name it so the operator knows to quiesce the account first. - logx.Warn("mailbox %s/%s: source still changing after %d re-scans — the account is written faster than it copies; quiesce it and re-run --apply", srcDom, user, maxRescans) + logx.Warn("mailbox %s/%s: source still changing after %d re-scans, the account is written faster than it copies; quiesce it and re-run --apply", srcDom, user, maxRescans) return res, fmt.Errorf("%s/%s -> %s/%s: batch %d/%d: source still changing after %d re-scans (written faster than it copies): %w", srcDom, user, destDom, user, i+1, len(steps), maxRescans, err) } rescans++ @@ -223,7 +223,7 @@ func (t Transfer) SyncBoxProgressDomains(ctx context.Context, srcDom, destDom, u if rr, ok := prog.(RescanReporter); ok { rr.Rescan(i+1, len(steps), len(vanished), len(appeared)) } else { - logx.Warn("mailbox %s: source changed during copy (%d message(s) vanished, %d appeared) — re-scanned, continuing with the missing blocks only", srcRel, len(vanished), len(appeared)) + logx.Warn("mailbox %s: source changed during copy (%d message(s) vanished, %d appeared), re-scanned, continuing with the missing blocks only", srcRel, len(vanished), len(appeared)) } logx.Debug("re-scan %s (#%d): vanished e.g. %v; appeared e.g. %v", srcRel, rescans, firstN(vanished, 5), firstN(appeared, 5)) prevSrc = freshSrc @@ -302,7 +302,7 @@ func (t Transfer) listFiles(ctx context.Context, rel string) ([]FileEntry, error // legitimately empty before the first sync), a queued source mailbox with no // maildir at all is anomalous — Warn so it is not silently treated as an empty // "already in sync" no-op (the exact "an active mailbox didn't migrate" case). - logx.Warn("source mailbox %s has no maildir directory — treating as empty (nothing to copy); confirm the account exists on the source", rel) + logx.Warn("source mailbox %s has no maildir directory, treating as empty (nothing to copy); confirm the account exists on the source", rel) } return files, nil } diff --git a/internal/maildir/verify_set.go b/internal/maildir/verify_set.go index 170c2b9d..99fef76e 100644 --- a/internal/maildir/verify_set.go +++ b/internal/maildir/verify_set.go @@ -305,7 +305,7 @@ done ` // GetMessageDigests returns the sha256 of every message BODY in a mailbox, keyed by -// FOLDER-AWARE identity, read-only (the --deep-verify mail check). It forks sha256sum +// FOLDER-AWARE identity, read-only (the --deep-verify mail check). It runs sha256sum // per message (portable; bounded because it is opt-in); only the digests cross the // wire, never the message bodies. dom/user passed via env, never interpolated. A body // that cannot be hashed is emitted with the digestUnreadable sentinel (surfaced as diff --git a/internal/maildir/verify_set_test.go b/internal/maildir/verify_set_test.go index 44ac813c..9af5f05c 100644 --- a/internal/maildir/verify_set_test.go +++ b/internal/maildir/verify_set_test.go @@ -100,7 +100,7 @@ func TestSameCountDifferentContent(t *testing.T) { t.Fatalf("precondition: counts should match (2 == 2)") } if SameMessageSet(src, dest) { - t.Error("sets with same count but different members must NOT be equal — this is the bug --verify-checksums fixes") + t.Error("sets with same count but different members must NOT be equal, this is the bug --verify-checksums fixes") } onlySrc, onlyDest := DiffMessageSets(src, dest, 10) if len(onlySrc) != 1 || onlySrc[0] != "INBOX/200.M2.h" { diff --git a/internal/migrate/analyze_webfiles.go b/internal/migrate/analyze_webfiles.go index 5b94b8c2..82271433 100644 --- a/internal/migrate/analyze_webfiles.go +++ b/internal/migrate/analyze_webfiles.go @@ -39,7 +39,7 @@ func analyzeWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log msg := log.Yellow("no destination domain yet") if reason, blocked := domainBlocked(pd, it.Domain); blocked { marker = "!" - msg = log.Red("BLOCKED — " + reason) + msg = log.Red("BLOCKED: " + reason) } else if hasNote(it.Notes, "canonical domain collision") { marker = "!" msg = log.Red(skipReason(it.Notes)) @@ -54,11 +54,11 @@ func analyzeWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log func(domain string, res webfiles.GatherResult, prog *logx.Progress) { switch { case res.Unreadable: - prog.Replace(itemStr(log, "!", domain, "%s", log.Red("source docroot UNREADABLE (permission denied) — fix permissions; NOT migrated"))) + prog.Replace(itemStr(log, "!", domain, "%s", log.Red("source docroot UNREADABLE (permission denied), fix permissions; NOT migrated"))) case res.Absent: prog.Replace(itemStr(log, "!", domain, "%s", log.Yellow("source docroot absent"))) case res.Count == 0: - prog.Replace(itemStr(log, "·", domain, "source docroot empty — on apply: existing destination backed up to -bak, then emptied")) + prog.Replace(itemStr(log, "·", domain, "source docroot empty, on apply: existing destination backed up to -bak, then emptied")) default: prog.Replace(itemStr(log, "=", domain, "%s (%d files, %s)", log.Green("ready"), res.Count, report.HumanBytes(res.Bytes))) diff --git a/internal/migrate/apply.go b/internal/migrate/apply.go index 2ad3ae36..8c97e461 100644 --- a/internal/migrate/apply.go +++ b/internal/migrate/apply.go @@ -7,14 +7,88 @@ import ( "context" "fmt" "os" + "sort" "strings" "github.com/tis24dev/cPanel_self-migration/internal/config" + "github.com/tis24dev/cPanel_self-migration/internal/events" "github.com/tis24dev/cPanel_self-migration/internal/logx" "github.com/tis24dev/cPanel_self-migration/internal/report" "github.com/tis24dev/cPanel_self-migration/internal/sshx" ) +// applyItem is one per-item outcome inside an apply phase event's Data +// payload (PR 7C). Item is the natural identifier (mailbox address, domain, +// database); Status uses the same vocabulary as the report lines (migrated, +// unchanged, skipped, failed, unverified). Note carries the already-reported +// reason string, never raw commands or secrets. +type applyItem struct { + Item string `json:"item"` + Status string `json:"status"` + Note string `json:"note,omitempty"` +} + +// Event Data payloads for the apply phases. Concrete types (not ad-hoc maps) +// so tests can assert them and the events.jsonl serialization stays stable. +// phase_completed means "the phase ran to completion", NOT "every item +// succeeded": per-item failures live here, in the tally, and in the final +// process error that drives report.json's exit_status. +type domainApplyEventData struct { + FailedDomains []string `json:"failed_domains"` + BlockedDomains []string `json:"blocked_domains"` +} + +type mailApplyEventData struct { + Items []applyItem `json:"items"` + Failed int `json:"failed"` + Unverified int `json:"unverified"` +} + +type webApplyEventData struct { + Failed int `json:"failed"` +} + +type dbApplyEventData struct { + Migrated []string `json:"migrated"` + Failed int `json:"failed"` + ConfigNotRewritten int `json:"config_not_rewritten"` + ConfigUnmigrated int `json:"config_unmigrated"` +} + +type verifyEventData struct { + Divergent int `json:"divergent"` +} + +// domainEventData collects the per-domain apply outcomes that persist on pd +// (creation failures and preflight blocks), sorted for determinism. Failed +// keys are canonical domain keys; blocked keys are the domain names. +func domainEventData(pd migrationData) domainApplyEventData { + d := domainApplyEventData{ + FailedDomains: make([]string, 0, len(pd.FailedDomains)), + BlockedDomains: make([]string, 0, len(pd.BlockedDomains)), + } + for name := range pd.FailedDomains { + d.FailedDomains = append(d.FailedDomains, name) + } + for name := range pd.BlockedDomains { + d.BlockedDomains = append(d.BlockedDomains, name) + } + sort.Strings(d.FailedDomains) + sort.Strings(d.BlockedDomains) + return d +} + +// sortedDBNames returns the destination names of the successfully imported +// databases (the keys of destCreds), sorted for determinism. +func sortedDBNames(creds map[string]destCred) []string { + names := make([]string, 0, len(creds)) + for name := range creds { + names = append(names, name) + } + sort.Strings(names) + return names +} + // runApply performs the write phases, gated by the selection: // - create missing destination domains (shared; runs if mail OR files are // selected, because both need the destination docroot/account to exist); @@ -42,15 +116,32 @@ func runApply(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd migrat logx.Debug("runApply: flows mail=%v file=%v db=%v; mirror=%v deepVerify=%v verifyChecksums=%v forceSync=%v", opts.DoMail, opts.DoFile, opts.DoDB, opts.MirrorMail, opts.DeepVerify, opts.VerifyChecksums, opts.ForceSync) + em := opts.Events + evSrc := hostRef{User: cfg.Src.SSHUser, IP: cfg.Src.IP, Port: cfg.Src.Port} + evDest := hostRef{User: cfg.Dest.SSHUser, IP: cfg.Dest.IP, Port: cfg.Dest.Port} + phaseStart := func(ph events.Phase, msg string) { + emitEvent(em, opts.RunID, evSrc, evDest, ph, events.EventPhaseStarted, events.LevelInfo, msg, nil) + } + phaseDone := func(ph events.Phase, msg string, data any) { + emitEvent(em, opts.RunID, evSrc, evDest, ph, events.EventPhaseCompleted, events.LevelInfo, msg, data) + } + // phase error strings are report-line reasons, must stay secret-free; only Data is key-redacted by the writer. + phaseFail := func(ph events.Phase, err error) { + emitEvent(em, opts.RunID, evSrc, evDest, ph, events.EventPhaseFailed, events.LevelError, err.Error(), nil) + } + // Shared domain creation: a web docroot can only be filled once the addon/sub // exists on the destination, exactly like a mailbox needs its domain — and a // database's destination user is prefixed with the account, so the database // flow needs the destination account/domains to exist too. Runs when ANY flow // is selected (matches buildPipeline, which counts this step for mail||file||db). if opts.DoMail || opts.DoFile || opts.DoDB { + phaseStart(events.PhaseCreateDomains, "Creating missing destination domains") if err := applyDomains(ctx, pool, cfg, &pd, opts, log, rep); err != nil { + phaseFail(events.PhaseCreateDomains, err) return err } + phaseDone(events.PhaseCreateDomains, "domain step done", domainEventData(pd)) if ctx.Err() != nil { return ctx.Err() } @@ -64,10 +155,18 @@ func runApply(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd migrat var tally applyTally if opts.DoMail { + phaseStart(events.PhaseMigrateMail, "Migrating mailboxes") res, err := applyMailboxes(ctx, pool, cfg, pd, opts, log, rep) if err != nil { + phaseFail(events.PhaseMigrateMail, err) return err } + items := res.items + if items == nil { + items = []applyItem{} + } + phaseDone(events.PhaseMigrateMail, "mailbox migration done", + mailApplyEventData{Items: items, Failed: res.failed, Unverified: res.unverified}) tally.mailFailed = res.failed tally.mailUnverified = res.unverified if ctx.Err() != nil { @@ -75,10 +174,13 @@ func runApply(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd migrat } // --verify-checksums implies the deep mail content check too (it already // promises message-identity precision), alongside --deep-verify. + phaseStart(events.PhaseVerifyMail, "Verifying mailbox integrity") d, err := verify(ctx, pool, pd, log, rep, opts.DeepVerify || opts.VerifyChecksums) if err != nil { + phaseFail(events.PhaseVerifyMail, err) return err } + phaseDone(events.PhaseVerifyMail, "mailbox integrity check done", verifyEventData{Divergent: d}) tally.mailDiff = d if ctx.Err() != nil { return ctx.Err() @@ -86,18 +188,24 @@ func runApply(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd migrat } if opts.DoFile { + phaseStart(events.PhaseCopyFiles, "Copying website files") n, err := applyWebFiles(ctx, pool, pd, log, rep) if err != nil { + phaseFail(events.PhaseCopyFiles, err) return err } + phaseDone(events.PhaseCopyFiles, "web file copy done", webApplyEventData{Failed: n}) tally.webFailed = n if ctx.Err() != nil { return ctx.Err() } + phaseStart(events.PhaseVerifyFiles, "Verifying website files") d, err := verifyWebFiles(ctx, pool, pd, log, rep, opts.DeepVerify) if err != nil { + phaseFail(events.PhaseVerifyFiles, err) return err } + phaseDone(events.PhaseVerifyFiles, "web file verify done", verifyEventData{Divergent: d}) tally.webDiff = d if ctx.Err() != nil { return ctx.Err() @@ -110,22 +218,31 @@ func runApply(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd migrat // useful. The cPanel ACCOUNT credentials (the SSH password) double as the // MySQL credentials that can dump/import every database on each side. overrides := dbOverrides(cfg) + phaseStart(events.PhaseMigrateDB, "Migrating databases") destCreds, n, cfgUnrewritten, cfgUnmigrated, err := applyDBs(ctx, pool, pd, log, rep, cfg.Src.SSHUser, cfg.Src.SSHPass, overrides, opts.DeepVerify) if err != nil { + phaseFail(events.PhaseMigrateDB, err) return err } + phaseDone(events.PhaseMigrateDB, "database migration done", dbApplyEventData{ + Migrated: sortedDBNames(destCreds), Failed: n, + ConfigNotRewritten: cfgUnrewritten, ConfigUnmigrated: cfgUnmigrated, + }) tally.dbFailed = n tally.dbConfigNotRewritten = cfgUnrewritten tally.dbConfigUnmigrated = cfgUnmigrated if ctx.Err() != nil { return ctx.Err() } + phaseStart(events.PhaseVerifyDB, "Verifying databases") d, err := verifyDBs(ctx, pool, pd, log, rep, cfg.Src.SSHUser, cfg.Src.SSHPass, overrides, destCreds, opts.DeepVerify) if err != nil { + phaseFail(events.PhaseVerifyDB, err) return err } + phaseDone(events.PhaseVerifyDB, "database verify done", verifyEventData{Divergent: d}) tally.dbDiff = d if ctx.Err() != nil { return ctx.Err() @@ -185,14 +302,14 @@ func applyOutcome(t applyTally) error { add(t.webFailed, "docroot(s) failed to copy") add(t.dbFailed, "database(s) failed to migrate") add(t.dbConfigNotRewritten, "database(s) migrated but their site config was NOT rewritten (the site still points at the OLD database)") - add(t.dbConfigUnmigrated, "site(s) use a DB-config format this tool does not migrate/verify (Magento 1 / PrestaShop 1.7 / Symfony / SilverStripe) — set their destination DB by hand") + add(t.dbConfigUnmigrated, "site(s) use a DB-config format this tool does not migrate/verify (Magento 1 / PrestaShop 1.7 / Symfony / SilverStripe), set their destination DB by hand") add(t.mailDiff, "mailbox(es) still divergent after verify") add(t.webDiff, "docroot(s) still divergent after verify") add(t.dbDiff, "database(s) still divergent after verify") if len(parts) == 0 { return nil } - return fmt.Errorf("migration completed with issues: %s — see the FAIL/UNVERIFIED/skip/verify lines above and logs/migration_report.log", + return fmt.Errorf("migration completed with issues: %s, see the FAIL/UNVERIFIED/skip/verify lines above and logs/migration_report.log", strings.Join(parts, "; ")) } @@ -222,10 +339,10 @@ func openReport(opts Options, log *logx.Logger, srcRef, destRef, date string) (* return rep, func() { werr := rep.Err() if werr != nil { - log.Warn("migration_report.log writes failed (%v) — the on-screen record is complete, but the report file may be truncated", werr) + log.Warn("migration_report.log writes failed (%v), the on-screen record is complete, but the report file may be truncated", werr) } if cerr := rf.Close(); cerr != nil && werr == nil { - log.Warn("migration_report.log could not be committed (%v) — the on-screen record is complete, but logs/migration_report.log may be missing or stale", cerr) + log.Warn("migration_report.log could not be committed (%v), the on-screen record is complete, but logs/migration_report.log may be missing or stale", cerr) } }, nil } diff --git a/internal/migrate/apply_dbs.go b/internal/migrate/apply_dbs.go index cc587b25..a63eeeca 100644 --- a/internal/migrate/apply_dbs.go +++ b/internal/migrate/apply_dbs.go @@ -121,7 +121,7 @@ func applyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx. for i := range plan { it := plan[i] if ctx.Err() != nil { - log.Warn("interrupted — %d of %d databases processed; stopping", i, total) + log.Warn("interrupted, %d of %d databases processed; stopping", i, total) rep.FileOnlyf("INTERRUPTED after %d/%d databases.", i, total) return creds, failed, configUnrewritten, 0, ctx.Err() } @@ -139,13 +139,13 @@ func applyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx. for _, cfg := range it.Configs { cfgPaths = append(cfgPaths, cfg.ConfigPath) } - logx.Debug("applyDBs %s: skipped — %s; referencing config(s): %v", it.SrcDB, reason, cfgPaths) + logx.Debug("applyDBs %s: skipped: %s; referencing config(s): %v", it.SrcDB, reason, cfgPaths) if dbUnavailableReasonFailsDB(reason) { - rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL — "+reason)), + rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL: "+reason)), report.DBFailLine(it.SrcDB, reason)) failed++ } else { - rep.LogScreenFile(itemStr(log, "→", it.SrcDB, "%s", log.Yellow("skip — "+reason)), + rep.LogScreenFile(itemStr(log, "→", it.SrcDB, "%s", log.Yellow("skip: "+reason)), report.DBSkipLine(it.SrcDB, reason)) skipped++ } @@ -158,13 +158,13 @@ func applyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx. // sides of the collision are failed; the operator resolves the inventory and // re-runs. if reason, bad := conflicted[it.SrcDB]; bad { - rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL — unsafe plan: "+reason)), + rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL: unsafe plan: "+reason)), report.DBFailLine(it.SrcDB, "unsafe plan collision: "+reason)) failed++ continue } if reason, bad := invalidNames[it.SrcDB]; bad { - rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL — unsafe destination MySQL name: "+reason)), + rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL: unsafe destination MySQL name: "+reason)), report.DBFailLine(it.SrcDB, "unsafe destination MySQL name: "+reason)) failed++ continue @@ -176,7 +176,7 @@ func applyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx. if password == "" { gen, err := dbmig.GeneratePassword(dbmig.DefaultPasswordLen) if err != nil { - rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL — generate password: "+err.Error())), + rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL: generate password: "+err.Error())), report.DBFailLine(it.SrcDB, "generate password: "+err.Error())) failed++ continue @@ -190,7 +190,7 @@ func applyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx. if stopOnInterruptDuring(ctx, log, rep, it.SrcDB, "databases", i, total) { return creds, failed, configUnrewritten, 0, ctx.Err() } - rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL — "+err.Error())), + rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL: "+err.Error())), report.DBFailLine(it.SrcDB, err.Error())) failed++ continue @@ -199,7 +199,7 @@ func applyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx. if stopOnInterruptDuring(ctx, log, rep, it.SrcDB, "databases", i, total) { return creds, failed, configUnrewritten, 0, ctx.Err() } - rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL — "+err.Error())), + rep.LogScreenFile(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL: "+err.Error())), report.DBFailLine(it.SrcDB, err.Error())) failed++ continue @@ -217,7 +217,7 @@ func applyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx. prog.Replace(itemStr(log, "→", it.SrcDB, "%s", log.Yellow("interrupted"))) return creds, failed, configUnrewritten, 0, ctx.Err() } - prog.Replace(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL — "+err.Error()))) + prog.Replace(itemStr(log, "✗", it.SrcDB, "%s", log.Red("FAIL: "+err.Error()))) rep.FileOnlyf("%s", report.DBFailLine(it.SrcDB, err.Error())) failed++ continue @@ -243,7 +243,7 @@ func applyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx. } else if applied { logx.Debug("applyDBs %s: normalized dest DB default to %s/%s", it.DestDB, srcCS.DBCharset, srcCS.DBCollation) } else { - logx.Debug("applyDBs %s: dest DB default left as-is — %s", it.DestDB, reason) + logx.Debug("applyDBs %s: dest DB default left as-is, %s", it.DestDB, reason) } // 3) Rewrite every referencing wp-config on the destination. @@ -268,13 +268,13 @@ func applyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx. switch { case nUnrewritten > 0: verb = log.Yellow("migrated") - extra = fmt.Sprintf(", %d NOT rewritten — see report", nUnrewritten) + extra = fmt.Sprintf(", %d NOT rewritten, see report", nUnrewritten) case nNotVerified > 0: // Data migrated and the value/host checks passed, but a config's cutover could // not be independently verified (structurally ambiguous define). Not a clean // green, not a hard failure at the default tier — yellow with the count. verb = log.Yellow("migrated") - extra = fmt.Sprintf(", %d config(s) NOT independently verified — see report", nNotVerified) + extra = fmt.Sprintf(", %d config(s) NOT independently verified, see report", nNotVerified) } prog.Replace(itemStr(log, "✓", it.SrcDB, "%s -> %s (%s, %s; %d config(s) rewritten%s)", verb, it.DestDB, tableStr, report.HumanBytes(res.BytesSent), nRewritten, extra)) @@ -292,12 +292,12 @@ func applyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx. rep.FileOnlyf("") rep.FileOnlyf("Database migration summary: %d migrated, %d failed, %d skipped.", done, failed, skipped) if configUnrewritten > 0 { - rep.FileOnlyf(" %d database(s) migrated but their site config was NOT rewritten — the site still points at the OLD database.", configUnrewritten) - log.Warn("%d database(s) migrated but their site config was NOT rewritten — the site still points at the OLD database; finish the cutover manually", configUnrewritten) + rep.FileOnlyf(" %d database(s) migrated but their site config was NOT rewritten, the site still points at the OLD database.", configUnrewritten) + log.Warn("%d database(s) migrated but their site config was NOT rewritten, the site still points at the OLD database; finish the cutover manually", configUnrewritten) } if configNotVerified > 0 { - rep.FileOnlyf(" %d site config(s) were rewritten but their DB cutover could NOT be independently verified (structurally ambiguous) — run --deep-verify or confirm by hand.", configNotVerified) - log.Warn("%d site config(s) rewritten but their DB cutover could NOT be independently verified (structurally ambiguous define) — run --deep-verify or confirm by hand", configNotVerified) + rep.FileOnlyf(" %d site config(s) were rewritten but their DB cutover could NOT be independently verified (structurally ambiguous), run --deep-verify or confirm by hand.", configNotVerified) + log.Warn("%d site config(s) rewritten but their DB cutover could NOT be independently verified (structurally ambiguous define), run --deep-verify or confirm by hand", configNotVerified) } // Surface DB-config formats this tool does not discover/rewrite at all (Magento 1 // local.xml, PrestaShop 1.7 parameters.php, Symfony DATABASE_URL, SilverStripe). Their @@ -344,11 +344,11 @@ func reportUnmigratedDBConfigs(ctx context.Context, src *sshx.Client, pd migrati return 0 } for _, a := range apps { - log.Warn("MANUAL: %s detected at %s but its DB config (%s) is NOT migrated/verified — review the DB credentials by hand (the site still points at the OLD database)", a.App, a.Docroot, a.Marker) - rep.FileOnlyf(" [db config MANUAL] %s — %s detected (%s); its DB config is NOT migrated/verified, set the destination DB by hand", a.Docroot, a.App, a.Marker) + log.Warn("MANUAL: %s detected at %s but its DB config (%s) is NOT migrated/verified, review the DB credentials by hand (the site still points at the OLD database)", a.App, a.Docroot, a.Marker) + rep.FileOnlyf(" [db config MANUAL] %s: %s detected (%s); its DB config is NOT migrated/verified, set the destination DB by hand", a.Docroot, a.App, a.Marker) } if len(apps) > 0 { - rep.FileOnlyf(" %d site(s) use a DB-config format this tool does not migrate/verify (Magento 1 / PrestaShop 1.7 / Symfony / SilverStripe) — set their destination DB by hand.", len(apps)) + rep.FileOnlyf(" %d site(s) use a DB-config format this tool does not migrate/verify (Magento 1 / PrestaShop 1.7 / Symfony / SilverStripe), set their destination DB by hand.", len(apps)) } return len(apps) } @@ -416,10 +416,10 @@ func provisionDest(ctx context.Context, dest cpanel.Runner, it dbmig.DBPlanItem, // that follows decides the real success). func logProvisionStep(what string, err error) { if isAlreadyExists(err) { - logx.Debug("provisionDest: %s — already exists, continuing: %v", what, err) + logx.Debug("provisionDest: %s, already exists, continuing: %v", what, err) return } - logx.Debug("provisionDest: %s — non-fatal error, continuing (the import will surface a real failure): %v", what, err) + logx.Debug("provisionDest: %s, non-fatal error, continuing (the import will surface a real failure): %v", what, err) } // rewriteDestConfigs rewrites, on the destination, each wp-config.php that the @@ -451,42 +451,42 @@ func rewriteDestConfigs(ctx context.Context, dest *sshx.Client, pd migrationData // left on the old DB — surface it as a MANUAL step instead of dropping it. srcEntry, ok := srcDocrootContaining(pd, cfg.ConfigPath) if !ok { - log.Warn("MANUAL: config %s (db %s) NOT rewritten — no source docroot contains it; point it at db=%s user=%s by hand", + log.Warn("MANUAL: config %s (db %s) NOT rewritten, no source docroot contains it; point it at db=%s user=%s by hand", cfg.ConfigPath, it.SrcDB, it.DestDB, it.DestUser) - rep.FileOnlyf(" [db config MANUAL] %s — could not locate its docroot; set DB to %s / user %s by hand (was %s)", + rep.FileOnlyf(" [db config MANUAL] %s: could not locate its docroot; set DB to %s / user %s by hand (was %s)", cfg.ConfigPath, it.DestDB, it.DestUser, it.SrcDB) notRewritten++ continue } if reason, blocked := domainBlocked(pd, srcEntry.Domain); blocked { - log.Warn("MANUAL: config %s (db %s) NOT rewritten — %s; point it at db=%s user=%s by hand", + log.Warn("MANUAL: config %s (db %s) NOT rewritten, %s; point it at db=%s user=%s by hand", cfg.ConfigPath, it.SrcDB, reason, it.DestDB, it.DestUser) - rep.FileOnlyf(" [db config MANUAL] %s — %s; set DB to %s / user %s by hand (was %s)", + rep.FileOnlyf(" [db config MANUAL] %s: %s; set DB to %s / user %s by hand (was %s)", cfg.ConfigPath, reason, it.DestDB, it.DestUser, it.SrcDB) notRewritten++ continue } if issue, blocked := domainTypeIssue(pd, srcEntry.Domain); blocked && issue.BlockDBConfig { - log.Warn("MANUAL: config %s (db %s) NOT rewritten — %s; point it at db=%s user=%s by hand", + log.Warn("MANUAL: config %s (db %s) NOT rewritten, %s; point it at db=%s user=%s by hand", cfg.ConfigPath, it.SrcDB, issue.Reason(), it.DestDB, it.DestUser) - rep.FileOnlyf(" [db config MANUAL] %s — %s; set DB to %s / user %s by hand (was %s)", + rep.FileOnlyf(" [db config MANUAL] %s: %s; set DB to %s / user %s by hand (was %s)", cfg.ConfigPath, issue.Reason(), it.DestDB, it.DestUser, it.SrcDB) notRewritten++ continue } destDocroot, docrootIssue := destDocrootForChecked(pd, srcEntry.Domain) if docrootIssue != "" { - log.Warn("MANUAL: config %s (db %s) NOT rewritten — %s; point it at db=%s user=%s by hand", + log.Warn("MANUAL: config %s (db %s) NOT rewritten, %s; point it at db=%s user=%s by hand", cfg.ConfigPath, it.SrcDB, docrootIssue, it.DestDB, it.DestUser) - rep.FileOnlyf(" [db config MANUAL] %s — %s; set DB to %s / user %s by hand (was %s)", + rep.FileOnlyf(" [db config MANUAL] %s: %s; set DB to %s / user %s by hand (was %s)", cfg.ConfigPath, docrootIssue, it.DestDB, it.DestUser, it.SrcDB) notRewritten++ continue } if destDocroot == "" { - log.Warn("MANUAL: config %s (db %s) NOT rewritten — domain %s has no destination docroot; point it at db=%s user=%s by hand", + log.Warn("MANUAL: config %s (db %s) NOT rewritten, domain %s has no destination docroot; point it at db=%s user=%s by hand", cfg.ConfigPath, it.SrcDB, srcEntry.Domain, it.DestDB, it.DestUser) - rep.FileOnlyf(" [db config MANUAL] %s — domain %s has no destination docroot; set DB to %s / user %s by hand (was %s)", + rep.FileOnlyf(" [db config MANUAL] %s: domain %s has no destination docroot; set DB to %s / user %s by hand (was %s)", cfg.ConfigPath, srcEntry.Domain, it.DestDB, it.DestUser, it.SrcDB) notRewritten++ continue @@ -498,15 +498,15 @@ func rewriteDestConfigs(ctx context.Context, dest *sshx.Client, pd migrationData // The data migrated, but this CMS's config rewriter is not implemented // yet: make it a loud, actionable MANUAL step, not a buried warning, so // the site is not silently left pointing at the old database. - log.Warn("MANUAL: %s config %s NOT rewritten — point it at db=%s user=%s by hand (site still uses the old %s)", + log.Warn("MANUAL: %s config %s NOT rewritten, point it at db=%s user=%s by hand (site still uses the old %s)", ue.Kind, destPath, it.DestDB, it.DestUser, it.SrcDB) - rep.FileOnlyf(" [db config MANUAL] %s (%s) — set DB to %s / user %s by hand (was %s)", + rep.FileOnlyf(" [db config MANUAL] %s (%s): set DB to %s / user %s by hand (was %s)", destPath, ue.Kind, it.DestDB, it.DestUser, it.SrcDB) notRewritten++ continue } log.Warn("could not rewrite %s: %v", destPath, err) - rep.FileOnlyf(" [db config WARN] %s — %v", destPath, err) + rep.FileOnlyf(" [db config WARN] %s: %v", destPath, err) notRewritten++ continue } @@ -519,7 +519,7 @@ func rewriteDestConfigs(ctx context.Context, dest *sshx.Client, pd migrationData ok, reason, unverified, rerr := dbmig.VerifyDestConfig(ctx, dest, destPath, cfg.Kind, it.DestDB, it.DestUser, password) if rerr != nil { log.Warn("MANUAL: %s rewritten but could not be re-read to verify the cutover: %v", destPath, rerr) - rep.FileOnlyf(" [db config WARN] %s — rewritten but re-read to verify the cutover failed: %v", destPath, rerr) + rep.FileOnlyf(" [db config WARN] %s: rewritten but re-read to verify the cutover failed: %v", destPath, rerr) notRewritten++ continue } @@ -546,8 +546,8 @@ func rewriteDestConfigs(ctx context.Context, dest *sshx.Client, pd migrationData // Best-effort: a scan error/timeout does NOT fail the cutover (the value/host // check already passed and the scan is an additive net) — but surface it so a // non-completing scan is not silent, rather than hiding it at debug level. - log.Detail("source-cred containment scan of %s did not complete (%v) — cutover not re-scanned for stale source creds", destDocroot, lerr) - rep.FileOnlyf(" [db config note] %s — source-cred containment scan did not complete (%v); cutover not re-scanned for stale source creds", destDocroot, lerr) + log.Detail("source-cred containment scan of %s did not complete (%v), cutover not re-scanned for stale source creds", destDocroot, lerr) + rep.FileOnlyf(" [db config note] %s: source-cred containment scan did not complete (%v); cutover not re-scanned for stale source creds", destDocroot, lerr) } else if leak { ok, unverified, reason = false, true, lreason } @@ -561,19 +561,19 @@ func rewriteDestConfigs(ctx context.Context, dest *sshx.Client, pd migrationData // data migrated and the value/host checks held), a HARD failure under // --deep-verify (which must not green-light an unproven cutover). if deep { - log.Warn("MANUAL: %s rewritten but its DB cutover could NOT be independently verified (%s) — confirm it by hand (--deep-verify)", destPath, reason) - rep.FileOnlyf(" [db config UNVERIFIED] %s — %s; cutover not provable (--deep-verify)", destPath, reason) + log.Warn("MANUAL: %s rewritten but its DB cutover could NOT be independently verified (%s), confirm it by hand (--deep-verify)", destPath, reason) + rep.FileOnlyf(" [db config UNVERIFIED] %s: %s; cutover not provable (--deep-verify)", destPath, reason) notRewritten++ continue } - log.Detail("%s rewritten; DB cutover NOT independently verified (%s) — run --deep-verify to certify", destPath, reason) + log.Detail("%s rewritten; DB cutover NOT independently verified (%s), run --deep-verify to certify", destPath, reason) rep.FileOnlyf("%s", report.DBConfigUnverifiedLine(it.DestDB, destPath, reason)) notVerified++ continue } if !ok { - log.Warn("MANUAL: %s does NOT point at the destination database after rewrite (%s) — fix it by hand", destPath, reason) - rep.FileOnlyf(" [db config MANUAL] %s — does not point at the destination DB after rewrite: %s", destPath, reason) + log.Warn("MANUAL: %s does NOT point at the destination database after rewrite (%s), fix it by hand", destPath, reason) + rep.FileOnlyf(" [db config MANUAL] %s: does not point at the destination DB after rewrite: %s", destPath, reason) notRewritten++ continue } @@ -608,7 +608,7 @@ func verifyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx var ok, diff, realDiff, eventsOnDest int for _, it := range plan { if ctx.Err() != nil { - log.Warn("interrupted — database verification stopped") + log.Warn("interrupted, database verification stopped") return realDiff, ctx.Err() } // A database whose every referencing domain is unavailable was SKIPPED (or @@ -618,7 +618,7 @@ func verifyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx // mislabeled UNVERIFIED with generic "migration did not complete" re-run advice. if skip, reason := dbAllDomainsUnavailableForApply(pd, it); skip { rep.LogScreenFile( - itemStr(log, "→", it.DestDB, "%s — %s", log.Yellow("SKIP"), reason), + itemStr(log, "→", it.DestDB, "%s: %s", log.Yellow("SKIP"), reason), report.DBSkipLine(it.DestDB, reason+"; counted under apply")) continue } @@ -729,7 +729,7 @@ func verifyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx // real divergence (an unverifiable DB is not a pass), matching how the // mailbox verify treats its UNREADABLE verdict. rep.LogScreenFile( - itemStr(log, "~", it.DestDB, "%s — could not read %s table count", + itemStr(log, "~", it.DestDB, "%s: could not read %s table count", log.Red("UNREADABLE"), unreadableSide(errS, errD)), report.DBVerifyLine(it.DestDB, report.DBVerifyUnreadable, srcTables, destTables, srcObjStr, destObjStr)) diff++ @@ -747,7 +747,7 @@ func verifyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx // Surface the cosmetic default-collation difference (the migration could not // normalize it — the source collation does not exist on the destination // server) WITHOUT failing the verdict: existing tables/data are unaffected. - log.Detail("%s: DB default collation differs (%s) — cosmetic: affects only new tables created without an explicit COLLATE; existing tables/data unaffected", + log.Detail("%s: DB default collation differs (%s), cosmetic: affects only new tables created without an explicit COLLATE; existing tables/data unaffected", it.DestDB, charsetHeadline(srcCS, destCS, csDBDiff, len(csTableDiffs))) rep.FileOnlyf(" encoding (soft): %s", charsetDetail(srcCS, destCS, csDBDiff, csTableDiffs)) } @@ -765,35 +765,35 @@ func verifyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx status := report.DBVerifyDiff switch { case !schemaOK: - screen = itemStr(log, "~", it.DestDB, "%s — schema differs: %s", + screen = itemStr(log, "~", it.DestDB, "%s: schema differs: %s", log.Red("DIFF"), schemaDelta.Headline()) case srcTables != destTables: - screen = itemStr(log, "~", it.DestDB, "%s — SRC(tables=%d) DEST(tables=%d)", log.Red("DIFF"), srcTables, destTables) + screen = itemStr(log, "~", it.DestDB, "%s: SRC(tables=%d) DEST(tables=%d)", log.Red("DIFF"), srcTables, destTables) case len(deepRes.MissingTables) > 0 || len(deepRes.ExtraTables) > 0: - screen = itemStr(log, "~", it.DestDB, "%s — table set differs: %s", + screen = itemStr(log, "~", it.DestDB, "%s: table set differs: %s", log.Red("DIFF"), deepRes.TableSetHeadline()) case len(deepRes.RowDiffs) > 0: // tables/objects equal — rows were lost - screen = itemStr(log, "~", it.DestDB, "%s — row counts differ: %s", + screen = itemStr(log, "~", it.DestDB, "%s: row counts differ: %s", log.Red("DIFF"), strings.Join(firstN(deepRes.RowDiffs, 3), ", ")) case len(deepRes.ChecksumDiffs) > 0: - screen = itemStr(log, "~", it.DestDB, "%s — content checksums differ: %s", + screen = itemStr(log, "~", it.DestDB, "%s: content checksums differ: %s", log.Red("DIFF"), strings.Join(firstN(deepRes.ChecksumDiffs, 3), ", ")) case len(deepRes.ObjectDiffs) > 0: - screen = itemStr(log, "~", it.DestDB, "%s — object definition differs: %s", + screen = itemStr(log, "~", it.DestDB, "%s: object definition differs: %s", log.Red("DIFF"), strings.Join(firstN(deepRes.ObjectDiffs, 3), ", ")) case deepUnverified: status = report.DBVerifyUnverified - screen = itemStr(log, "~", it.DestDB, "%s — row-count/content check incomplete: %s", + screen = itemStr(log, "~", it.DestDB, "%s: row-count/content check incomplete: %s", log.Red("UNVERIFIED"), deepRes.UnverifiedReason()) case csUnverified: status = report.DBVerifyUnverified // Charset metadata could not be read on at least one side — the encoding // leg of the verdict is unproven. Report UNVERIFIED, never a mojibake DIFF // (we observed no divergence) and never a clean OK. - screen = itemStr(log, "~", it.DestDB, "%s — encoding could not be read on %s", + screen = itemStr(log, "~", it.DestDB, "%s: encoding could not be read on %s", log.Red("UNVERIFIED"), unreadableSide(errSC, errDC)) default: // tables/objects equal — the divergence is the encoding - screen = itemStr(log, "~", it.DestDB, "%s — encoding differs (mojibake risk): %s", + screen = itemStr(log, "~", it.DestDB, "%s: encoding differs (mojibake risk): %s", log.Red("DIFF"), charsetHeadline(srcCS, destCS, csDBDiff, len(csTableDiffs))) } rep.LogScreenFile(screen, report.DBVerifyLine(it.DestDB, status, srcTables, destTables, srcObjStr, destObjStr)) @@ -823,7 +823,7 @@ func verifyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx rep.FileOnlyf(" rows: %s", rd) } if len(deepRes.RowDiffs) > 0 { - rep.FileOnlyf(" NOTE: fewer rows on the destination means a partial import OR a source still receiving writes after the dump snapshot — re-run --apply --db to re-copy (as the final sync shortly before cutover if the source is live).") + rep.FileOnlyf(" NOTE: fewer rows on the destination means a partial import OR a source still receiving writes after the dump snapshot, re-run --apply --db to re-copy (as the final sync shortly before cutover if the source is live).") } if len(deepRes.ChecksumDiffs) > 0 { log.Detail("%s: %d table(s) with equal row count but different content checksum (same server version)", @@ -839,7 +839,7 @@ func verifyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx if deep { rep.FileOnlyf(" content UNVERIFIED: %s", deepRes.UnverifiedReason()) } else { - rep.FileOnlyf(" content not byte-verified (%s); row counts match — run --deep-verify to certify", deepRes.UnverifiedReason()) + rep.FileOnlyf(" content not byte-verified (%s); row counts match, run --deep-verify to certify", deepRes.UnverifiedReason()) } } if len(deepRes.AutoIncrDiffs) > 0 { @@ -855,15 +855,15 @@ func verifyDBs(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx if diff == 0 { log.OK("database integrity check passed: %d database(s) consistent", ok) } else { - log.Warn("%d database(s) differ — re-run --apply --db to re-copy", diff) + log.Warn("%d database(s) differ, re-run --apply --db to re-copy", diff) rep.FileOnlyf(" re-run --apply --db to re-copy the divergent databases.") } // Events are imported but only RUN when the destination MySQL has // event_scheduler=ON — a global a non-root cPanel user cannot set. Flag it so // the operator (or host admin) enables it; this is operational, not a failure. if eventsOnDest > 0 { - log.Detail("%d event(s) migrated — ensure event_scheduler=ON on the destination MySQL for them to run", eventsOnDest) - rep.FileOnlyf(" NOTE: %d event(s) migrated — the destination MySQL needs event_scheduler=ON for them to run.", eventsOnDest) + log.Detail("%d event(s) migrated, ensure event_scheduler=ON on the destination MySQL for them to run", eventsOnDest) + rep.FileOnlyf(" NOTE: %d event(s) migrated, the destination MySQL needs event_scheduler=ON for them to run.", eventsOnDest) } return realDiff, nil } @@ -1107,10 +1107,10 @@ func deepDB(ctx context.Context, pool *sshx.Pool, it dbmig.DBPlanItem, srcUser, // FLOAT/JSON formatting artifact) fall back to the existing "content unchecked" // verdict. It can only UPGRADE to certified, never invent a false hard diff. if crossEngineContentMatches(ctx, pool, it, srcUser, srcPass, dc, checkable) { - logx.Debug("deep db %s: cross-engine content fingerprint matched for %d equal-row table(s) — content certified despite version diff (src=%q dest=%q)", it.DestDB, len(checkable), srcInfo.Version, destInfo.Version) + logx.Debug("deep db %s: cross-engine content fingerprint matched for %d equal-row table(s), content certified despite version diff (src=%q dest=%q)", it.DestDB, len(checkable), srcInfo.Version, destInfo.Version) } else { checksumUnchecked = fmt.Sprintf("server versions differ or are unknown (src=%q dest=%q)", srcInfo.Version, destInfo.Version) - logx.Debug("deep db %s: server versions differ (src=%q dest=%q) — checksum skipped; engine-independent fingerprint did not certify", it.DestDB, srcInfo.Version, destInfo.Version) + logx.Debug("deep db %s: server versions differ (src=%q dest=%q), checksum skipped; engine-independent fingerprint did not certify", it.DestDB, srcInfo.Version, destInfo.Version) } } res := diffDeepTables(srcInfo, destInfo, srcCk, destCk) @@ -1167,7 +1167,7 @@ func crossEngineContentMatches(ctx context.Context, pool *sshx.Pool, it dbmig.DB srcCols, errS := dbmig.TableColumns(ctx, pool.Src, it.SrcDB, srcUser, srcPass) destCols, errD := dbmig.TableColumns(ctx, pool.Dest, it.DestDB, dc.user, dc.pass) if errS != nil || errD != nil { - logx.Debug("cross-engine fingerprint %s: could not read columns (src=%v dest=%v) — not certifying", it.DestDB, errS, errD) + logx.Debug("cross-engine fingerprint %s: could not read columns (src=%v dest=%v), not certifying", it.DestDB, errS, errD) return false } // Require the column NAME+ORDER to be IDENTICAL on both sides for every table before @@ -1182,7 +1182,7 @@ func crossEngineContentMatches(ctx context.Context, pool *sshx.Pool, it dbmig.DB sc, sok := srcCols[t] dcl, dok := destCols[t] if !sok || !dok || len(sc) == 0 || !sameColumns(sc, dcl) { - logx.Debug("cross-engine fingerprint %s: column set differs/absent for table %q — not certifying", it.DestDB, t) + logx.Debug("cross-engine fingerprint %s: column set differs/absent for table %q, not certifying", it.DestDB, t) return false } want[t] = sc @@ -1190,7 +1190,7 @@ func crossEngineContentMatches(ctx context.Context, pool *sshx.Pool, it dbmig.DB srcFP, e1 := dbmig.ContentFingerprints(ctx, pool.Src, it.SrcDB, srcUser, srcPass, want) destFP, e2 := dbmig.ContentFingerprints(ctx, pool.Dest, it.DestDB, dc.user, dc.pass, want) if e1 != nil || e2 != nil { - logx.Debug("cross-engine fingerprint %s: read failed (src=%v dest=%v) — not certifying", it.DestDB, e1, e2) + logx.Debug("cross-engine fingerprint %s: read failed (src=%v dest=%v), not certifying", it.DestDB, e1, e2) return false } for t := range want { diff --git a/internal/migrate/apply_domains.go b/internal/migrate/apply_domains.go index adbdf504..0a070689 100644 --- a/internal/migrate/apply_domains.go +++ b/internal/migrate/apply_domains.go @@ -57,11 +57,11 @@ func applyDomains(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd *m warnBlockedDomains(*pd, log, domRep) domRep.Summary() if n := len(pd.BlockedDomains); n > 0 { - log.Warn("domain creation step done with %d BLOCKED selected domain(s) — dependent mail/files/databases will be skipped and the run will end with an error", n) + log.Warn("domain creation step done with %d BLOCKED selected domain(s), dependent mail/files/databases will be skipped and the run will end with an error", n) } else if pd.SrcDocroots == nil && pd.DestDocroots == nil { log.OK("no selected domains need creation") } else { - log.OK("no selected domains need creation — refreshed docroots") + log.OK("no selected domains need creation, refreshed docroots") } return nil } @@ -156,7 +156,7 @@ func applyDomains(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd *m warnBlockedDomains(*pd, log, domRep) domRep.Summary() if failed, blocked := len(pd.FailedDomains), len(pd.BlockedDomains); failed > 0 || blocked > 0 { - log.Warn("domain creation step done with %d FAILED and %d BLOCKED domain(s) — dependent mail/files/databases will be skipped and the run will end with an error", failed, blocked) + log.Warn("domain creation step done with %d FAILED and %d BLOCKED domain(s), dependent mail/files/databases will be skipped and the run will end with an error", failed, blocked) } else { log.OK("domain creation step done") } @@ -210,7 +210,7 @@ func plannedDomainCreates(pd migrationData, uses []selectedDomainUse) (addons, s func markAbsentCreatedDomainsFailed(pd *migrationData, created []string, log *logx.Logger, rep *domainReport) { for _, dom := range created { if !domainname.Has(pd.DestDomainSet, dom) && !domainFailed(*pd, dom) { - log.Warn("%s create reported success but the domain is ABSENT from the destination domain list — treating as FAILED (its mail/files/databases will be skipped). Re-run if this was a provisioning lag.", dom) + log.Warn("%s create reported success but the domain is ABSENT from the destination domain list, treating as FAILED (its mail/files/databases will be skipped). Re-run if this was a provisioning lag.", dom) markDomainFailed(pd, dom) rep.Failed(dom, "create reported success but domain absent after refresh; dependent mail/files/databases skipped") } @@ -267,7 +267,7 @@ func warnBlockedDomains(pd migrationData, log *logx.Logger, rep *domainReport) { } sort.Strings(domains) for _, domain := range domains { - log.Warn("%s — %s; dependent mail/files/databases will be skipped and the run will end with an error", domain, pd.BlockedDomains[domain]) + log.Warn("%s, %s; dependent mail/files/databases will be skipped and the run will end with an error", domain, pd.BlockedDomains[domain]) rep.Blocked(domain, pd.BlockedDomains[domain]+"; dependent mail/files/databases skipped") } } @@ -315,7 +315,7 @@ func reconcileDomainErrors(pendingErr map[string]domErr, destSet map[string]bool for dom, de := range pendingErr { switch { case domainname.Has(destSet, dom): - log.Item("%s already present on destination — treated as created (idempotent)", dom) + log.Item("%s already present on destination, treated as created (idempotent)", dom) rep.Present(dom, de.kind) logx.Debug("reconcileDomainErrors: %s (%s) create error was benign (exists on dest now): %v", dom, de.kind, de.err) case isAlreadyExists(de.err): @@ -328,11 +328,11 @@ func reconcileDomainErrors(pendingErr map[string]domErr, destSet map[string]bool // resolves on a re-run (the domain then appears in the list and is // processed), and a domain that "already exists" under ANOTHER cPanel // account is a real conflict to resolve, not a silent skip. - log.Warn("%s %s reported 'already exists' but is ABSENT from the destination domain list — treating as FAILED (its mail/files/databases will be skipped). If this was a provisioning lag, re-run; if the domain exists under another account, resolve the conflict: %v", de.kind, dom, de.err) + log.Warn("%s %s reported 'already exists' but is ABSENT from the destination domain list, treating as FAILED (its mail/files/databases will be skipped). If this was a provisioning lag, re-run; if the domain exists under another account, resolve the conflict: %v", de.kind, dom, de.err) markDomainFailed(pd, dom) rep.Failed(dom, fmt.Sprintf("%s reported 'already exists' but domain absent after refresh: %v", de.kind, de.err)) default: - log.Warn("%s %s FAILED — its mail/files/databases will be skipped: %v", de.kind, dom, de.err) + log.Warn("%s %s FAILED, its mail/files/databases will be skipped: %v", de.kind, dom, de.err) markDomainFailed(pd, dom) rep.Failed(dom, fmt.Sprintf("%s create failed: %v", de.kind, de.err)) } diff --git a/internal/migrate/apply_mailboxes.go b/internal/migrate/apply_mailboxes.go index b94adc9d..9fbe85dc 100644 --- a/internal/migrate/apply_mailboxes.go +++ b/internal/migrate/apply_mailboxes.go @@ -19,6 +19,11 @@ import ( type mailboxApplyResult struct { failed int unverified int + items []applyItem +} + +func (r *mailboxApplyResult) record(email, status, note string) { + r.items = append(r.items, applyItem{Item: email, Status: status, Note: note}) } // mirrorVanishHook, if non-nil, is invoked under --apply-mirror right before the @@ -93,26 +98,28 @@ func applyMailboxes(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd // Stop immediately if interrupted (Ctrl-C), instead of churning through // the remaining mailboxes with closed connections. if ctx.Err() != nil { - log.Warn("interrupted — %d of %d mailboxes processed; stopping", i, total) + log.Warn("interrupted, %d of %d mailboxes processed; stopping", i, total) rep.Logf("INTERRUPTED after %d/%d mailboxes.", i, total) return finish(), ctx.Err() } email := m.Email() if issue, ok := domainTypeIssue(pd, m.Domain); ok && issue.WarnMail && !reportedTypeWarnings[domainname.Key(m.Domain)] { - rep.FileOnlyf(" [domain WARN] %s — %s; mail will still be attempted", m.Domain, issue.Reason()) + rep.FileOnlyf(" [domain WARN] %s: %s; mail will still be attempted", m.Domain, issue.Reason()) reportedTypeWarnings[domainname.Key(m.Domain)] = true } // Skip everything tied to a domain whose creation failed earlier. if domainFailed(pd, m.Domain) { reason := "domain '" + m.Domain + "' creation failed" - rep.LogScreenFile(itemStr(log, "→", email, "%s", log.Yellow("skip — "+reason)), report.SkipLine(email, reason)) + rep.LogScreenFile(itemStr(log, "→", email, "%s", log.Yellow("skip: "+reason)), report.SkipLine(email, reason)) + result.record(email, "skipped", reason) skipped++ continue } if reason, blocked := domainBlocked(pd, m.Domain); blocked { - rep.LogScreenFile(itemStr(log, "→", email, "%s", log.Yellow("skip — "+reason)), report.SkipLine(email, reason)) + rep.LogScreenFile(itemStr(log, "→", email, "%s", log.Yellow("skip: "+reason)), report.SkipLine(email, reason)) + result.record(email, "skipped", reason) skipped++ continue } @@ -120,20 +127,23 @@ func applyMailboxes(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd // Safety: destination domain must exist. if !domainname.Has(pd.DestDomainSet, m.Domain) { reason := "destination domain '" + m.Domain + "' not configured" - rep.LogScreenFile(itemStr(log, "→", email, "%s", log.Yellow("skip — "+reason)), report.SkipLine(email, reason)) + rep.LogScreenFile(itemStr(log, "→", email, "%s", log.Yellow("skip: "+reason)), report.SkipLine(email, reason)) + result.record(email, "skipped", reason) skipped++ continue } if m.Hash == "" { reason := "no password hash found on source; account/password not applied" - rep.LogScreenFile(itemStr(log, "~", email, "%s", log.Red("UNVERIFIED — "+reason)), report.UnverifiedLine(email, reason)) + rep.LogScreenFile(itemStr(log, "~", email, "%s", log.Red("UNVERIFIED: "+reason)), report.UnverifiedLine(email, reason)) + result.record(email, "unverified", reason) result.unverified++ continue } destDomain, ok := destDomainNameFor(pd, m.Domain) if !ok { reason := destDomainResolutionIssue(pd, m.Domain) - rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL — "+reason)), report.FailLine(email, reason)) + rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL: "+reason)), report.FailLine(email, reason)) + result.record(email, "failed", reason) failed++ continue } @@ -146,7 +156,8 @@ func applyMailboxes(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd if stopOnInterrupt(ctx, log, rep, email, i, total) { return finish(), ctx.Err() } - rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL — account step: "+err.Error())), report.FailLine(email, "account step: "+err.Error())) + rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL: account step: "+err.Error())), report.FailLine(email, "account step: "+err.Error())) + result.record(email, "failed", "account step: "+err.Error()) failed++ continue } @@ -158,7 +169,7 @@ func applyMailboxes(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd // a non-routine event — surface it prominently on screen, not just in // the report. log.Warn("orphan maildir for %s renamed to %q on dest before re-creating the account", email, ens.BackedUpDir) - rep.FileOnlyf(" [backup] %s — orphan maildir renamed to %s on dest", email, ens.BackedUpDir) + rep.FileOnlyf(" [backup] %s: orphan maildir renamed to %s on dest", email, ens.BackedUpDir) } // --apply-mirror: make the destination an EXACT mirror of the source. @@ -193,13 +204,15 @@ func applyMailboxes(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd if stopOnInterrupt(ctx, log, rep, email, i, total) { return finish(), ctx.Err() } - rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL — mirror prep: source unreadable: "+err.Error())), report.FailLine(email, "mirror prep: source mailbox unreadable ("+err.Error()+"); live destination left intact (not mirrored)")) + rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL: mirror prep: source unreadable: "+err.Error())), report.FailLine(email, "mirror prep: source mailbox unreadable ("+err.Error()+"); live destination left intact (not mirrored)")) + result.record(email, "failed", "mirror prep: source mailbox unreadable ("+err.Error()+")") failed++ continue } if !srcPresent { reason := "mirror prep: source mailbox mail/" + m.Domain + "/" + m.User + " absent on source; live destination left intact (not mirrored)" - rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL — "+reason)), report.FailLine(email, reason)) + rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL: "+reason)), report.FailLine(email, reason)) + result.record(email, "failed", reason) failed++ continue } @@ -220,7 +233,8 @@ func applyMailboxes(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd return finish(), ctx.Err() } reason := "mirror prep: could not read source occupancy (" + err.Error() + "); live destination left intact (not mirrored)" - rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL — "+reason)), report.FailLine(email, reason)) + rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL: "+reason)), report.FailLine(email, reason)) + result.record(email, "failed", reason) failed++ continue } @@ -231,14 +245,15 @@ func applyMailboxes(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd if stopOnInterrupt(ctx, log, rep, email, i, total) { return finish(), ctx.Err() } - rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL — mirror prep: "+err.Error())), report.FailLine(email, "mirror prep: "+err.Error())) + rep.LogScreenFile(itemStr(log, "✗", email, "%s", log.Red("FAIL: mirror prep: "+err.Error())), report.FailLine(email, "mirror prep: "+err.Error())) + result.record(email, "failed", "mirror prep: "+err.Error()) failed++ continue } mirrorBackedUpDir = mr.BackedUpDir if mr.BackedUpDir != "" { log.Warn("--apply-mirror: dest mailbox for %s renamed to %q before re-copy", email, mr.BackedUpDir) - rep.FileOnlyf(" [mirror] %s — dest mailbox renamed to %s before re-copy", email, mr.BackedUpDir) + rep.FileOnlyf(" [mirror] %s: dest mailbox renamed to %s before re-copy", email, mr.BackedUpDir) } else { logx.Debug("applyMailboxes %s: --apply-mirror, dest mailbox absent/empty, nothing to set aside", email) } @@ -280,6 +295,7 @@ func applyMailboxes(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd if skip { logx.Debug("applyMailboxes %s: fast-skip succeeded (msg+UIDVALIDITY consistent, VerifyChecksums=%v)", email, opts.VerifyChecksums) rep.LogScreenFile(itemStr(log, "=", email, "%s", log.Green("unchanged")+" (msg+UIDVALIDITY match)"), report.UnchangedLine(email)) + result.record(email, "unchanged", "") unchanged++ continue } @@ -304,8 +320,9 @@ func applyMailboxes(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd prog.replace(itemStr(log, "→", email, "%s", log.Yellow("interrupted"))) return finish(), ctx.Err() } - prog.replace(itemStr(log, "✗", email, "%s", log.Red("FAIL — mailbox copy failed: "+err.Error()))) + prog.replace(itemStr(log, "✗", email, "%s", log.Red("FAIL: mailbox copy failed: "+err.Error()))) rep.FileOnlyf("%s", report.FailLine(email, "mailbox copy failed: "+err.Error())) + result.record(email, "failed", "mailbox copy failed: "+err.Error()) failed++ continue } @@ -331,16 +348,18 @@ func applyMailboxes(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd if stopOnInterrupt(ctx, log, rep, email, i, total) { return finish(), ctx.Err() } - reason := "mirror verify: could not confirm destination occupancy after the copy (" + err.Error() + "); the previous live mailbox is preserved in " + mirrorBackedUpDir + " — investigate before trusting this mailbox as migrated" - prog.replace(itemStr(log, "✗", email, "%s", log.Red("FAIL — "+reason))) + reason := "mirror verify: could not confirm destination occupancy after the copy (" + err.Error() + "); the previous live mailbox is preserved in " + mirrorBackedUpDir + ", investigate before trusting this mailbox as migrated" + prog.replace(itemStr(log, "✗", email, "%s", log.Red("FAIL: "+reason))) rep.FileOnlyf("%s", report.FailLine(email, reason)) + result.record(email, "failed", reason) failed++ continue } if mirrorEmptiedLiveDest(mirrorBackedUpDir, srcGateMsgCount, destStats.MsgCount) { - reason := fmt.Sprintf("mirror left the destination EMPTY: the source held %d message(s) at the occupancy gate but none reached the destination — the source was emptied or removed around mirror time (a TOCTOU race and a concurrent full expunge are indistinguishable here). The previous live mailbox is preserved in %s; investigate and recover from there before trusting this mailbox as migrated", srcGateMsgCount, mirrorBackedUpDir) - prog.replace(itemStr(log, "✗", email, "%s", log.Red("FAIL — "+reason))) + reason := fmt.Sprintf("mirror left the destination EMPTY: the source held %d message(s) at the occupancy gate but none reached the destination, the source was emptied or removed around mirror time (a TOCTOU race and a concurrent full expunge are indistinguishable here). The previous live mailbox is preserved in %s; investigate and recover from there before trusting this mailbox as migrated", srcGateMsgCount, mirrorBackedUpDir) + prog.replace(itemStr(log, "✗", email, "%s", log.Red("FAIL: "+reason))) rep.FileOnlyf("%s", report.FailLine(email, reason)) + result.record(email, "failed", reason) failed++ continue } @@ -348,12 +367,13 @@ func applyMailboxes(ctx context.Context, pool *sshx.Pool, cfg config.Config, pd if res.FilesSent == 0 { // Nothing was missing — fast-skip didn't catch it (e.g. UIDVALIDITY // changed) but every message is already present. - prog.replace(itemStr(log, "✓", email, "%s — account %s, already in sync", log.Green("synced"), state)) + prog.replace(itemStr(log, "✓", email, "%s: account %s, already in sync", log.Green("synced"), state)) rep.FileOnlyf("%s", report.OKLine(email, string(state))+" (already in sync)") } else { - prog.replace(itemStr(log, "✓", email, "%s — account %s, %d messages + %d control = %d files", log.Green("synced"), state, res.MsgTotal, res.ControlTotal, res.FilesTotal)) - rep.FileOnlyf("%s", report.OKLine(email, string(state))+fmt.Sprintf(" — %d messages + %d control = %d files", res.MsgTotal, res.ControlTotal, res.FilesTotal)) + prog.replace(itemStr(log, "✓", email, "%s: account %s, %d messages + %d control = %d files", log.Green("synced"), state, res.MsgTotal, res.ControlTotal, res.FilesTotal)) + rep.FileOnlyf("%s", report.OKLine(email, string(state))+fmt.Sprintf(", %d messages + %d control = %d files", res.MsgTotal, res.ControlTotal, res.FilesTotal)) } + result.record(email, "migrated", "") done++ } @@ -381,7 +401,7 @@ func stopOnInterruptDuring(ctx context.Context, log *logx.Logger, rep *report.Re if ctx.Err() == nil { return false } - log.Warn("interrupted during %s — %d of %d %s processed; stopping", item, processed, total, unit) + log.Warn("interrupted during %s, %d of %d %s processed; stopping", item, processed, total, unit) rep.Logf("INTERRUPTED during %s after %d/%d %s.", item, processed, total, unit) return true } diff --git a/internal/migrate/apply_mailboxes_mirror_test.go b/internal/migrate/apply_mailboxes_mirror_test.go index 9752eee9..2b77652a 100644 --- a/internal/migrate/apply_mailboxes_mirror_test.go +++ b/internal/migrate/apply_mailboxes_mirror_test.go @@ -238,7 +238,7 @@ func TestMirrorEmptiedLiveDest(t *testing.T) { {"vanish: live mail set aside, source had mail, dest empty", "info-bak", 5, 0, true}, {"healthy: dest refilled to the source count", "info-bak", 5, 5, false}, {"healthy: dest refilled (count differs but non-empty)", "info-bak", 5, 4, false}, - {"partial: dest below the gate count but non-empty — left to verify", "info-bak", 5, 1, false}, + {"partial: dest below the gate count but non-empty, left to verify", "info-bak", 5, 1, false}, {"empty source: occupancy 0 mirrors to empty legitimately", "info-bak", 0, 0, false}, {"nobak: dest was already empty, no live data lost", "", 5, 0, false}, {"nobak + empty source", "", 0, 0, false}, diff --git a/internal/migrate/apply_webfiles.go b/internal/migrate/apply_webfiles.go index 69f41be0..bf43587c 100644 --- a/internal/migrate/apply_webfiles.go +++ b/internal/migrate/apply_webfiles.go @@ -44,7 +44,7 @@ func applyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log * for _, issue := range issues { issueDomains[issue.Domain] = true reason := issue.Reason - rep.LogScreenFile(itemStr(log, "✗", issue.Domain, "%s", log.Red("FAIL — "+reason)), report.WebFailLine(issue.Domain, reason)) + rep.LogScreenFile(itemStr(log, "✗", issue.Domain, "%s", log.Red("FAIL: "+reason)), report.WebFailLine(issue.Domain, reason)) } typeFailures := reportWebTypeIssueFailures(pd, items, log, rep, issueDomains) var blocked int @@ -52,8 +52,8 @@ func applyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log * if issueDomains[it.Domain] { continue } - reason := "not attempted — web-file destination preflight failed" - rep.LogScreenFile(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip — "+reason)), report.WebSkipLine(it.Domain, reason)) + reason := "not attempted, web-file destination preflight failed" + rep.LogScreenFile(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip: "+reason)), report.WebSkipLine(it.Domain, reason)) blocked++ } // Items the normal copy loop would FAIL outright but that actionable filtering @@ -78,7 +78,7 @@ func applyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log * var done, skipped, failed int for i, it := range items { if ctx.Err() != nil { - log.Warn("interrupted — %d of %d docroots processed; stopping", i, total) + log.Warn("interrupted, %d of %d docroots processed; stopping", i, total) rep.FileOnlyf("INTERRUPTED after %d/%d docroots.", i, total) return failed, ctx.Err() } @@ -86,18 +86,18 @@ func applyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log * // Skip a docroot whose domain failed creation earlier (instant row). if domainFailed(pd, it.Domain) { reason := "domain '" + it.Domain + "' creation failed" - rep.LogScreenFile(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip — "+reason)), report.WebSkipLine(it.Domain, reason)) + rep.LogScreenFile(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip: "+reason)), report.WebSkipLine(it.Domain, reason)) skipped++ continue } if reason, blocked := domainBlocked(pd, it.Domain); blocked { - rep.LogScreenFile(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip — "+reason)), report.WebSkipLine(it.Domain, reason)) + rep.LogScreenFile(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip: "+reason)), report.WebSkipLine(it.Domain, reason)) skipped++ continue } if issue, blocked := domainTypeIssue(pd, it.Domain); blocked && issue.BlockWeb { reason := issue.Reason() - rep.LogScreenFile(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL — "+reason)), report.WebFailLine(it.Domain, reason)) + rep.LogScreenFile(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL: "+reason)), report.WebFailLine(it.Domain, reason)) failed++ continue } @@ -107,13 +107,13 @@ func applyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log * if it.Skip || it.DestDocroot == "" { if hasNote(it.Notes, "canonical domain collision") { reason := skipReason(it.Notes) - rep.LogScreenFile(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL — "+reason)), report.WebFailLine(it.Domain, reason)) + rep.LogScreenFile(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL: "+reason)), report.WebFailLine(it.Domain, reason)) failed++ continue } if destinationDomainMissingDocroot(pd, it.Domain) { reason := "destination domain exists but has no destination docroot in DomainInfo::domains_data" - rep.LogScreenFile(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL — "+reason)), report.WebFailLine(it.Domain, reason)) + rep.LogScreenFile(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL: "+reason)), report.WebFailLine(it.Domain, reason)) failed++ continue } @@ -121,7 +121,7 @@ func applyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log * if reason == "" { reason = "no destination docroot" } - rep.LogScreenFile(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip — "+reason)), report.WebSkipLine(it.Domain, reason)) + rep.LogScreenFile(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip: "+reason)), report.WebSkipLine(it.Domain, reason)) skipped++ continue } @@ -140,7 +140,7 @@ func applyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log * prog.Replace(itemStr(log, "→", it.Domain, "%s", log.Yellow("interrupted"))) return failed, ctx.Err() } - prog.Replace(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL — "+err.Error()))) + prog.Replace(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL: "+err.Error()))) rep.FileOnlyf("%s", report.WebFailLine(it.Domain, err.Error())) failed++ continue @@ -153,16 +153,16 @@ func applyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log * if res.FilesTotal == 0 { switch { case res.BackedUpDir != "": - prog.Replace(itemStr(log, "→", it.Domain, "%s", log.Yellow("source empty — backed up existing destination to "+res.BackedUpDir))) - rep.FileOnlyf("%s", report.WebSkipLine(it.Domain, "source empty — existing destination backed up to "+res.BackedUpDir)) + prog.Replace(itemStr(log, "→", it.Domain, "%s", log.Yellow("source empty, backed up existing destination to "+res.BackedUpDir))) + rep.FileOnlyf("%s", report.WebSkipLine(it.Domain, "source empty, existing destination backed up to "+res.BackedUpDir)) default: - prog.Replace(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip — source docroot empty — destination already empty"))) - rep.FileOnlyf("%s", report.WebSkipLine(it.Domain, "source docroot empty — destination already empty")) + prog.Replace(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip: source docroot empty, destination already empty"))) + rep.FileOnlyf("%s", report.WebSkipLine(it.Domain, "source docroot empty, destination already empty")) } skipped++ continue } - prog.Replace(itemStr(log, "✓", it.Domain, "%s — %d/%d files copied (%s)", log.Green("copied"), res.FilesSent, res.FilesTotal, report.HumanBytes(res.BytesSent))) + prog.Replace(itemStr(log, "✓", it.Domain, "%s: %d/%d files copied (%s)", log.Green("copied"), res.FilesSent, res.FilesTotal, report.HumanBytes(res.BytesSent))) rep.FileOnlyf("%s", report.WebOKLine(it.Domain, res.FilesSent, res.FilesTotal, res.BytesSent)) done++ } @@ -184,7 +184,7 @@ func reportWebTypeIssueFailures(pd migrationData, items []webfiles.WebPlanItem, continue } reason := issue.Reason() - rep.LogScreenFile(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL — "+reason)), report.WebFailLine(it.Domain, reason)) + rep.LogScreenFile(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL: "+reason)), report.WebFailLine(it.Domain, reason)) already[it.Domain] = true failed++ } @@ -215,7 +215,7 @@ func reportWebInstantFailures(pd migrationData, items []webfiles.WebPlanItem, lo default: continue // a benign no-destination skip, not a failure } - rep.LogScreenFile(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL — "+reason)), report.WebFailLine(it.Domain, reason)) + rep.LogScreenFile(itemStr(log, "✗", it.Domain, "%s", log.Red("FAIL: "+reason)), report.WebFailLine(it.Domain, reason)) failed++ } return failed @@ -269,7 +269,7 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log var ok, diff int for _, it := range items { if ctx.Err() != nil { - log.Warn("interrupted — web verification stopped") + log.Warn("interrupted, web verification stopped") return diff, ctx.Err() } // Verify only docroots the copy step actually targeted. Mirror the copy gate @@ -290,7 +290,7 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log if deep { if b, _, okb, _, berr := webfiles.CountBytes(ctx, pool.Src, it.SrcDocroot); berr == nil && okb && b > webfiles.DeepByteCap { useDeep = false - rep.FileOnlyf(" DEEP-SKIPPED %s: %s exceeds the deep-verify cap (%s) — verified by metadata", + rep.FileOnlyf(" DEEP-SKIPPED %s: %s exceeds the deep-verify cap (%s), verified by metadata", it.Domain, report.HumanBytes(b), report.HumanBytes(webfiles.DeepByteCap)) } } @@ -309,8 +309,8 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log } }) if serr != nil { - prog.Replace(itemStr(log, "~", it.Domain, "%s — source manifest: %v", log.Red("UNREADABLE"), serr)) - rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "UNREADABLE — source manifest error")) + prog.Replace(itemStr(log, "~", it.Domain, "%s: source manifest: %v", log.Red("UNREADABLE"), serr)) + rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "UNREADABLE: source manifest error")) diff++ continue } @@ -318,8 +318,8 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log // content could not be read, so it must not verify clean (the empty/absent skip // below would otherwise swallow it). Surface UNREADABLE and fail the run. if srcUnreadable { - prog.Replace(itemStr(log, "~", it.Domain, "%s — source docroot present but unreadable", log.Red("UNREADABLE"))) - rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "UNREADABLE — source docroot present but not readable; mirror could not be verified")) + prog.Replace(itemStr(log, "~", it.Domain, "%s: source docroot present but unreadable", log.Red("UNREADABLE"))) + rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "UNREADABLE: source docroot present but not readable; mirror could not be verified")) diff++ continue } @@ -334,8 +334,8 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log // and BEFORE the truncation fallback (a known-incomplete evidence set outranks the // coarser count+bytes check, which does not apply the parser's safety guard). if srcDropped > 0 { - prog.Replace(itemStr(log, "~", it.Domain, "%s — %d source path(s) have unsupported names (tab/control-byte/traversal); cannot verify", log.Red("UNVERIFIED"), srcDropped)) - rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, fmt.Sprintf("UNVERIFIED — %d source path(s) dropped as unsupported (tab/control-byte/traversal in the filename); the mirror could not be verified for them. Re-running will not help — rename the source path(s). See --log-level debug for each.", srcDropped))) + prog.Replace(itemStr(log, "~", it.Domain, "%s: %d source path(s) have unsupported names (tab/control-byte/traversal); cannot verify", log.Red("UNVERIFIED"), srcDropped)) + rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, fmt.Sprintf("UNVERIFIED: %d source path(s) dropped as unsupported (tab/control-byte/traversal in the filename); the mirror could not be verified for them. Re-running will not help, rename the source path(s). See --log-level debug for each.", srcDropped))) diff++ continue } @@ -344,7 +344,7 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log // nothing to verify — skip it exactly as the copy did, rather than flag the // preserved destination content as spurious "extra". if srcAbsent || (len(srcMan) == 0 && !srcTrunc) { - prog.Replace(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip — source empty/absent (destination left untouched)"))) + prog.Replace(itemStr(log, "→", it.Domain, "%s", log.Yellow("skip: source empty/absent (destination left untouched)"))) continue } @@ -359,8 +359,8 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log } }) if derr != nil { - prog.Replace(itemStr(log, "~", it.Domain, "%s — destination manifest: %v", log.Red("UNREADABLE"), derr)) - rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "UNREADABLE — destination manifest error")) + prog.Replace(itemStr(log, "~", it.Domain, "%s: destination manifest: %v", log.Red("UNREADABLE"), derr)) + rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "UNREADABLE: destination manifest error")) diff++ continue } @@ -368,8 +368,8 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log // manifest, making every source file look "missing" (a misleading DIFF). Report // it honestly as UNREADABLE instead. if destUnreadable { - prog.Replace(itemStr(log, "~", it.Domain, "%s — destination docroot present but unreadable", log.Red("UNREADABLE"))) - rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "UNREADABLE — destination docroot present but not readable; mirror could not be verified")) + prog.Replace(itemStr(log, "~", it.Domain, "%s: destination docroot present but unreadable", log.Red("UNREADABLE"))) + rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "UNREADABLE: destination docroot present but not readable; mirror could not be verified")) diff++ continue } @@ -385,15 +385,15 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log divergent, unverified, srcBytes, ferr := verifyWebFallback(ctx, pool, it, rep) switch { case ferr != nil: - prog.Replace(itemStr(log, "~", it.Domain, "%s — %v", log.Red("UNREADABLE"), ferr)) + prog.Replace(itemStr(log, "~", it.Domain, "%s: %v", log.Red("UNREADABLE"), ferr)) diff++ continue case unverified: - prog.Replace(itemStr(log, "~", it.Domain, "%s — namelist digest unavailable on host (manifest > cap; see report)", log.Red("UNVERIFIED"))) + prog.Replace(itemStr(log, "~", it.Domain, "%s: namelist digest unavailable on host (manifest > cap; see report)", log.Red("UNVERIFIED"))) diff++ continue case divergent: - prog.Replace(itemStr(log, "~", it.Domain, "%s — count/bytes/namelist differ (manifest > cap; see report)", log.Red("DIFF"))) + prog.Replace(itemStr(log, "~", it.Domain, "%s: count/bytes/namelist differ (manifest > cap; see report)", log.Red("DIFF"))) diff++ continue } @@ -413,16 +413,16 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log differ, contentNote, cerr := verifyWebContentDigest(ctx, pool, it, srcBytes) switch { case cerr != nil: - prog.Replace(itemStr(log, "~", it.Domain, "%s — content fingerprint: %v", log.Red("UNREADABLE"), cerr)) - rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, fmt.Sprintf("UNREADABLE — over-cap content fingerprint error: %v", cerr))) + prog.Replace(itemStr(log, "~", it.Domain, "%s: content fingerprint: %v", log.Red("UNREADABLE"), cerr)) + rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, fmt.Sprintf("UNREADABLE: over-cap content fingerprint error: %v", cerr))) diff++ case differ: - prog.Replace(itemStr(log, "~", it.Domain, "%s — file content differs at matching count/bytes/names (manifest > cap; run --deep-verify on an under-cap docroot to localize)", log.Red("DIFF"))) - rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "DIFF — file CONTENT differs above the manifest cap: count+bytes+name/size/type/symlink-target all matched but the tree content fingerprint diverges. The destination is NOT a faithful mirror. Re-run --apply --file to re-copy.")) + prog.Replace(itemStr(log, "~", it.Domain, "%s: file content differs at matching count/bytes/names (manifest > cap; run --deep-verify on an under-cap docroot to localize)", log.Red("DIFF"))) + rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "DIFF: file CONTENT differs above the manifest cap: count+bytes+name/size/type/symlink-target all matched but the tree content fingerprint diverges. The destination is NOT a faithful mirror. Re-run --apply --file to re-copy.")) diff++ case contentNote != "": - prog.Replace(itemStr(log, "~", it.Domain, "%s (count+bytes+namelist; manifest > cap; content NOT byte-verified — %s)", log.Yellow("OK"), contentNote)) - rep.FileOnlyf(" NOTE: file CONTENT was not byte-verified above the manifest cap — %s. Count+bytes+name/size/type/symlink-target matched. Use --deep-verify on an under-cap docroot for content hashes.", contentNote) + prog.Replace(itemStr(log, "~", it.Domain, "%s (count+bytes+namelist; manifest > cap; content NOT byte-verified, %s)", log.Yellow("OK"), contentNote)) + rep.FileOnlyf(" NOTE: file CONTENT was not byte-verified above the manifest cap, %s. Count+bytes+name/size/type/symlink-target matched. Use --deep-verify on an under-cap docroot for content hashes.", contentNote) ok++ default: prog.Replace(itemStr(log, "✓", it.Domain, "%s (count+bytes+namelist + content fingerprint; manifest > cap)", log.Green("OK"))) @@ -455,7 +455,7 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log // from the source for a benign reason. The source is guaranteed clean here // (srcDropped>0 already continued above), so skip the content hash and note it // rather than raise a false content DIFF. - contentNote = fmt.Sprintf("%d destination path(s) have unsupported names (tab/control-byte) — content fingerprint skipped to avoid a false mismatch", destDropped) + contentNote = fmt.Sprintf("%d destination path(s) have unsupported names (tab/control-byte), content fingerprint skipped to avoid a false mismatch", destDropped) } else if !deep && md.Hard() == 0 { var srcBytes int64 for _, e := range srcMan { @@ -464,13 +464,13 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log differ, note, cerr := verifyWebContentDigest(ctx, pool, it, srcBytes) switch { case cerr != nil: - prog.Replace(itemStr(log, "~", it.Domain, "%s — content fingerprint: %v", log.Red("UNREADABLE"), cerr)) - rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, fmt.Sprintf("UNREADABLE — content fingerprint error: %v", cerr))) + prog.Replace(itemStr(log, "~", it.Domain, "%s: content fingerprint: %v", log.Red("UNREADABLE"), cerr)) + rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, fmt.Sprintf("UNREADABLE: content fingerprint error: %v", cerr))) diff++ continue case differ: - prog.Replace(itemStr(log, "~", it.Domain, "%s — file content differs at matching path/size/type (run --deep-verify to localize the file)", log.Red("DIFF"))) - rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "DIFF — file CONTENT differs at equal path/size/type/symlink-target (tree content fingerprint mismatch). Run --deep-verify to localize the diverging file(s).")) + prog.Replace(itemStr(log, "~", it.Domain, "%s: file content differs at matching path/size/type (run --deep-verify to localize the file)", log.Red("DIFF"))) + rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, "DIFF: file CONTENT differs at equal path/size/type/symlink-target (tree content fingerprint mismatch). Run --deep-verify to localize the diverging file(s).")) diff++ continue default: @@ -481,9 +481,9 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log if md.OK() { switch { case contentNote != "": - prog.Replace(itemStr(log, "~", it.Domain, "%s (manifest=%d entries; content NOT byte-verified — %s)", log.Yellow("OK"), len(srcMan), contentNote)) + prog.Replace(itemStr(log, "~", it.Domain, "%s (manifest=%d entries; content NOT byte-verified, %s)", log.Yellow("OK"), len(srcMan), contentNote)) rep.FileOnlyf("%s", report.WebManifestOKLine(it.Domain, len(srcMan))) - rep.FileOnlyf(" NOTE: file CONTENT was not byte-verified — %s. Path/size/type/mode/symlink-target matched. Use --deep-verify for per-file content hashes.", contentNote) + rep.FileOnlyf(" NOTE: file CONTENT was not byte-verified, %s. Path/size/type/mode/symlink-target matched. Use --deep-verify for per-file content hashes.", contentNote) case !deep: prog.Replace(itemStr(log, "✓", it.Domain, "%s (manifest=%d entries, content verified)", log.Green("OK"), len(srcMan))) rep.FileOnlyf("%s", report.WebManifestOKLine(it.Domain, len(srcMan))) @@ -502,7 +502,7 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log label = log.Red("DIFF") diff++ } - prog.Replace(itemStr(log, "~", it.Domain, "%s — %s", label, summary)) + prog.Replace(itemStr(log, "~", it.Domain, "%s: %s", label, summary)) rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, summary)) for _, ex := range md.Examples { rep.FileOnlyf(" %s", ex) @@ -514,7 +514,7 @@ func verifyWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log if diff == 0 { log.OK("web-file integrity check passed: %d docroot(s) consistent", ok) } else { - log.Warn("%d web docroot(s) differ — re-run --apply --file to re-copy", diff) + log.Warn("%d web docroot(s) differ, re-run --apply --file to re-copy", diff) rep.FileOnlyf(" re-run --apply --file to re-copy the divergent docroots.") } return diff, nil @@ -627,7 +627,7 @@ func verifyWebFallback(ctx context.Context, pool *sshx.Pool, it webfiles.WebPlan // spurious DIFF. (A present EMPTY tree digests to the non-empty empty-input sha256, so // this only triggers on genuinely-unavailable tools.) if (sok && sfp == "") || (dok && dfp == "") { - rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, fmt.Sprintf("UNVERIFIED — namelist digest unavailable (sha256sum / sort -z missing on the host); docroot exceeds the manifest cap (%d entries) so names/types could not be verified", webfiles.DefaultManifestCap))) + rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, fmt.Sprintf("UNVERIFIED: namelist digest unavailable (sha256sum / sort -z missing on the host); docroot exceeds the manifest cap (%d entries) so names/types could not be verified", webfiles.DefaultManifestCap))) return false, true, 0, nil } // An unreadable side (a subtree we could not read) cannot certify the mirror at name @@ -635,7 +635,7 @@ func verifyWebFallback(ctx context.Context, pool *sshx.Pool, it webfiles.WebPlan // a generic DIFF, hiding the "could not read" cause; surface it as UNVERIFIED (fail // closed), like the missing-tools case above, before the match calc. if sunread || dunread { - rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, fmt.Sprintf("UNVERIFIED — could not read one side fully (docroot exceeds the manifest cap of %d entries); names/types not certified", webfiles.DefaultManifestCap))) + rep.FileOnlyf("%s", report.WebManifestDiffLine(it.Domain, fmt.Sprintf("UNVERIFIED: could not read one side fully (docroot exceeds the manifest cap of %d entries); names/types not certified", webfiles.DefaultManifestCap))) return false, true, 0, nil } // A clean match requires both sides fully readable AND equal count+bytes AND equal @@ -644,12 +644,12 @@ func verifyWebFallback(ctx context.Context, pool *sshx.Pool, it webfiles.WebPlan // human-readable shortfall numbers in the report. match := sok && dok && !sunread && !dunread && sc == dc && sb == db && sfp == dfp rep.FileOnlyf("%s", report.WebVerifyLine(it.Domain, match, sc, dc, sb, db)) - rep.FileOnlyf(" NOTE: docroot exceeded the manifest cap (%d entries) — verified by count+bytes+namelist digest (names/sizes/types/symlink-targets).", webfiles.DefaultManifestCap) + rep.FileOnlyf(" NOTE: docroot exceeded the manifest cap (%d entries), verified by count+bytes+namelist digest (names/sizes/types/symlink-targets).", webfiles.DefaultManifestCap) if !match && sok && dok && sc == dc && sb == db { // Counts and bytes are equal but the digests differ — surface the otherwise- // puzzling "DIFF at equal count+bytes" (a rename, an equal-size add/remove, or // a type/symlink-target change). - rep.FileOnlyf(" namelist digest differs (src=%s dest=%s) — rename, equal-size add/remove, or type/symlink-target change", shortDigest(sfp), shortDigest(dfp)) + rep.FileOnlyf(" namelist digest differs (src=%s dest=%s), rename, equal-size add/remove, or type/symlink-target change", shortDigest(sfp), shortDigest(dfp)) } return !match, false, sb, nil } diff --git a/internal/migrate/compare.go b/internal/migrate/compare.go index d76f230c..6951fe5a 100644 --- a/internal/migrate/compare.go +++ b/internal/migrate/compare.go @@ -125,7 +125,7 @@ func compareDryRun(ctx context.Context, c boxStatReader, pd migrationData, log * var domPresent, domCreate, domBlocked int for _, d := range pd.SrcDomains { if reason, blocked := domainBlocked(pd, d.Name); blocked { - item(log, "!", d.Name, "%s", log.Red("BLOCKED — "+reason)) + item(log, "!", d.Name, "%s", log.Red("BLOCKED: "+reason)) domBlocked++ continue } @@ -145,10 +145,10 @@ func compareDryRun(ctx context.Context, c boxStatReader, pd migrationData, log * item(log, "=", d.Name, "%s", log.Green("present on both")) domPresent++ case model.CreateAddon: - item(log, "+", d.Name, "%s", log.Yellow("MISSING on dest — will create (addon)")) + item(log, "+", d.Name, "%s", log.Yellow("MISSING on dest, will create (addon)")) domCreate++ case model.CreateSub: - item(log, "+", d.Name, "%s", log.Yellow("MISSING on dest — will create (subdomain)")) + item(log, "+", d.Name, "%s", log.Yellow("MISSING on dest, will create (subdomain)")) domCreate++ } } @@ -172,12 +172,12 @@ func compareDryRun(ctx context.Context, c boxStatReader, pd migrationData, log * var identical, differs, toMigrate, toMirror, destAhead, unknown, blockedMail int for _, m := range pd.Mailboxes { if ctx.Err() != nil { - log.Warn("interrupted — mailbox comparison stopped") + log.Warn("interrupted, mailbox comparison stopped") break } email := m.Email() if reason, blocked := domainBlocked(pd, m.Domain); blocked { - item(log, "!", email, "%s", log.Red("BLOCKED — "+reason)) + item(log, "!", email, "%s", log.Red("BLOCKED: "+reason)) blockedMail++ continue } @@ -187,7 +187,7 @@ func compareDryRun(ctx context.Context, c boxStatReader, pd migrationData, log * if !domainname.Has(pd.DestDomainSet, m.Domain) { if mirror { if src, err := c.stats(ctx, true, m.Domain, m.User); err == nil { - item(log, "+", email, "%s — src %d msg (dest domain missing)", log.Yellow("will mirror"), src.MsgCount) + item(log, "+", email, "%s: src %d msg (dest domain missing)", log.Yellow("will mirror"), src.MsgCount) } else { logx.Debug("compareDryRun %s: --apply-mirror source stat read failed (dest domain missing): %v", email, err) item(log, "+", email, "%s (dest domain missing)", log.Yellow("will mirror")) @@ -229,11 +229,11 @@ func compareDryRun(ctx context.Context, c boxStatReader, pd migrationData, log * if mirror { if dst.MsgCount > src.MsgCount { - prog.Replace(itemStr(log, "~", email, "%s — dest %d > src %d (≥ %d dest-only msg moved aside to -bak)", + prog.Replace(itemStr(log, "~", email, "%s: dest %d > src %d (≥ %d dest-only msg moved aside to -bak)", log.Yellow("will mirror"), dst.MsgCount, src.MsgCount, dst.MsgCount-src.MsgCount)) destAhead++ } else { - prog.Replace(itemStr(log, "→", email, "%s — src %d msg", log.Green("will mirror"), src.MsgCount)) + prog.Replace(itemStr(log, "→", email, "%s: src %d msg", log.Green("will mirror"), src.MsgCount)) } toMirror++ continue diff --git a/internal/migrate/compare_dbs.go b/internal/migrate/compare_dbs.go index 9ff173f6..ee44c884 100644 --- a/internal/migrate/compare_dbs.go +++ b/internal/migrate/compare_dbs.go @@ -27,10 +27,10 @@ func compareDBs(pd migrationData, log *logx.Logger, overrides map[string]dbmig.O // referenced domain fails creation first and removes one side from the run, which // is why this is worded as "would" rather than a categorical promise.) for _, c := range dbmig.ValidatePlan(plan) { - log.Warn("unsafe DB plan: %s — --apply would refuse the colliding databases", c.Detail) + log.Warn("unsafe DB plan: %s, --apply would refuse the colliding databases", c.Detail) } for _, v := range dbPlanNameViolations(pd, plan) { - log.Warn("unsafe DB name plan: %s — --apply would refuse this database before provisioning", v.Detail) + log.Warn("unsafe DB name plan: %s, --apply would refuse this database before provisioning", v.Detail) } var willCreate, alreadyThere, skipped int @@ -38,7 +38,7 @@ func compareDBs(pd migrationData, log *logx.Logger, overrides map[string]dbmig.O for _, it := range plan { if skip, reason := dbAllDomainsUnavailableForApply(pd, it); skip { skipped++ - item(log, "→", it.SrcDB, "%s", log.Yellow("skip — "+reason)) + item(log, "→", it.SrcDB, "%s", log.Yellow("skip: "+reason)) continue } if it.DiskUsage > 0 { // a negative/unknown cPanel disk figure must not shrink the total @@ -47,7 +47,7 @@ func compareDBs(pd migrationData, log *logx.Logger, overrides map[string]dbmig.O typeIssueReasons := dbConfigTypeIssueReasons(pd, it) if destSet[it.DestDB] { alreadyThere++ - item(log, "~", it.SrcDB, "%s -> %s %s (will overwrite — migration)", log.Yellow("exists on dest"), it.DestDB, report.HumanBytes(it.DiskUsage)) + item(log, "~", it.SrcDB, "%s -> %s %s (will overwrite, migration)", log.Yellow("exists on dest"), it.DestDB, report.HumanBytes(it.DiskUsage)) } else { willCreate++ item(log, "→", it.SrcDB, "%s -> %s %s", log.Green("will create"), it.DestDB, report.HumanBytes(it.DiskUsage)) diff --git a/internal/migrate/compare_webfiles.go b/internal/migrate/compare_webfiles.go index 94d82f96..aef9c5eb 100644 --- a/internal/migrate/compare_webfiles.go +++ b/internal/migrate/compare_webfiles.go @@ -28,7 +28,7 @@ func compareWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log for _, it := range items { if reason, blocked := domainBlocked(pd, it.Domain); blocked { willSkip++ - item(log, "!", it.Domain, "%s", log.Red("BLOCKED — "+reason)) + item(log, "!", it.Domain, "%s", log.Red("BLOCKED: "+reason)) continue } if issue, blocked := domainTypeIssue(pd, it.Domain); blocked && issue.BlockWeb { @@ -42,7 +42,7 @@ func compareWebFiles(ctx context.Context, pool *sshx.Pool, pd migrationData, log item(log, "!", it.Domain, "%s", log.Red(skipReason(it.Notes))) case it.DestDocroot == "": willSkip++ - item(log, "+", it.Domain, "%s", log.Yellow("destination domain missing — create it first")) + item(log, "+", it.Domain, "%s", log.Yellow("destination domain missing, create it first")) case it.Skip && hasNote(it.Notes, "unreadable"): willSkip++ item(log, "!", it.Domain, "%s", log.Red(skipReason(it.Notes))) diff --git a/internal/migrate/data.go b/internal/migrate/data.go index b358a0f8..eb566840 100644 --- a/internal/migrate/data.go +++ b/internal/migrate/data.go @@ -105,7 +105,7 @@ func gatherData(ctx context.Context, p *sshx.Pool, log *logx.Logger, doMail, doF return } prog.Replace(itemStr(log, "✓", "inventory", "%s", - log.Green("read — domains"+gatherWhat(doMail, doFile, doDB)))) + log.Green("read: domains"+gatherWhat(doMail, doFile, doDB)))) }() op("reading source domains") @@ -324,7 +324,7 @@ func sourceOnlyGatherWhat(doFile, doDB bool) string { if len(parts) == 0 { return "" } - return " — " + strings.Join(parts, ", ") + return ", " + strings.Join(parts, ", ") } // gatherOpCount is the number of read operations gatherData performs for the diff --git a/internal/migrate/dbfiles_common.go b/internal/migrate/dbfiles_common.go index 356f8712..5c7f0448 100644 --- a/internal/migrate/dbfiles_common.go +++ b/internal/migrate/dbfiles_common.go @@ -160,7 +160,7 @@ func dbAllDomainsUnavailableForApply(pd migrationData, it dbmig.DBPlanItem) (boo // an unresolved reference breaks that proof, so migrate the DB rather than skip // it. Skipping a still-needed database loses its data; migrating a possibly // unneeded one is harmless (it lands as an orphan DB on the destination). - logx.Debug("applyDBs %s: a referencing config did not resolve to a source docroot — migrating instead of skipping (cannot prove all sites unavailable)", it.SrcDB) + logx.Debug("applyDBs %s: a referencing config did not resolve to a source docroot, migrating instead of skipping (cannot prove all sites unavailable)", it.SrcDB) return false, "" } switch { @@ -239,7 +239,7 @@ func dbNameMapping(pd migrationData) dbmig.NameMapping { // The source's authoritative Mysql::get_restrictions was unavailable, so the // account DB prefix is GUESSED from the first database name — a wrong guess // produces wrong destination DB/user names. Surface it (always-visible Warn). - logx.Warn("source Mysql::get_restrictions unavailable; deriving the account DB prefix %q from the first database name — verify the destination DB/user names", legacy) + logx.Warn("source Mysql::get_restrictions unavailable; deriving the account DB prefix %q from the first database name, verify the destination DB/user names", legacy) } logx.Debug("dbNameMapping: src prefix=%q (enabled=%v, restrictions known=%v), dest prefix=%q (enabled=%v)", src.Value, src.Enabled, mysqlRestrictionsKnown(pd.SrcMySQLRestrictions), dest.Value, dest.Enabled) diff --git a/internal/migrate/domain_type_issues.go b/internal/migrate/domain_type_issues.go index 200192fa..c24df2c8 100644 --- a/internal/migrate/domain_type_issues.go +++ b/internal/migrate/domain_type_issues.go @@ -96,6 +96,9 @@ func updateDomainTypeIssuesForUses(pd *migrationData, uses []selectedDomainUse) } func classifyDomainTypeIssue(src, dest model.Domain, doc cpanel.DomainDataEntry, hasDoc bool) (DomainTypeIssue, bool) { + if sameNameMainToMain(src, dest, doc, hasDoc) { + return DomainTypeIssue{}, false + } expected := model.ExpectedDestinationType(src.Type) mismatch := !model.CompatibleDestinationType(src.Type, dest.Type) issue := DomainTypeIssue{ @@ -136,6 +139,28 @@ func classifyDomainTypeIssue(src, dest model.Domain, doc cpanel.DomainDataEntry, return issue, true } +// sameNameMainToMain reports the 1:1 account-migration layout: the source MAIN +// domain exists on the destination as the destination account's own MAIN +// domain under the same FQDN (a destination account rebuilt for this exact +// domain). In that layout the destination main docroot IS the intended target +// of the migration, not another site's home, so no type issue is raised. +// The destination docroot must be present, unique and of type main_domain; +// anything else keeps the fail-closed blocking behavior. +// +// KEEP IN LOCKSTEP with WebPlanItem.AllowDestPublicHTMLRoot in +// internal/webfiles/plan.go (BuildPlan): this predicate is the authorization +// checkpoint that lets a web item run at all; that flag relaxes the +// public_html-root filesystem guard for the same layout. Loosening either +// without the other silently changes what the pair authorizes. +func sameNameMainToMain(src, dest model.Domain, doc cpanel.DomainDataEntry, hasDoc bool) bool { + docType, ok := domainTypeFromDocrootType(doc.Type) + return src.Type == model.Main && + dest.Type == model.Main && + domainname.Equal(src.Name, dest.Name) && + hasDoc && + ok && docType == model.Main +} + func selectedDomainUsesByDomain(uses []selectedDomainUse) map[string][]selectedDomainUse { out := map[string][]selectedDomainUse{} for _, use := range uses { diff --git a/internal/migrate/domain_type_issues_test.go b/internal/migrate/domain_type_issues_test.go index a8ce7f5f..f47b067e 100644 --- a/internal/migrate/domain_type_issues_test.go +++ b/internal/migrate/domain_type_issues_test.go @@ -29,6 +29,11 @@ func TestUpdateDomainTypeIssuesPolicy(t *testing.T) { {"main destination blocked", model.Addon, model.Main, "main_domain", true, true, "destination has main"}, {"conflicting docroot type blocked", model.Addon, model.Addon, "parked_domain", true, true, "docroot type parked_domain"}, {"unknown docroot type blocked", model.Addon, model.Addon, "weird_domain", true, true, "docroot type weird_domain"}, + {"same-name main to main allowed", model.Main, model.Main, "main_domain", false, false, ""}, + {"main to main with parked docroot blocked", model.Main, model.Main, "parked_domain", true, true, "docroot type parked_domain"}, + {"main to main with unknown docroot blocked", model.Main, model.Main, "weird_domain", true, true, "docroot type weird_domain"}, + {"main to parked still blocked", model.Main, model.Parked, "parked_domain", true, true, "destination has parked"}, + {"main to addon still expected mapping", model.Main, model.Addon, "addon_domain", false, false, ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -57,6 +62,44 @@ func TestUpdateDomainTypeIssuesPolicy(t *testing.T) { } } +func TestUpdateDomainTypeIssuesMainToMainWithoutDocrootFailsClosed(t *testing.T) { + pd := migrationData{ + SrcDomains: []model.Domain{{Name: "example.com", Type: model.Main}}, + DestDomains: []model.Domain{{Name: "example.com", Type: model.Main}}, + // No DestDocroots entry: the destination docroot cannot be validated, + // so the same-name main→main carve-out must NOT apply. + } + updateDomainTypeIssuesForUses(&pd, []selectedDomainUse{{Domain: "example.com", Flow: "web", Item: "/home/src/example.com"}}) + issue, ok := domainTypeIssue(pd, "example.com") + if !ok { + t.Fatalf("type issue missing for main→main without destination docroot: %+v", pd.DomainTypeIssues) + } + if !issue.BlockWeb || !issue.BlockDBConfig { + t.Fatalf("main→main without destination docroot should fail closed: %+v", issue) + } +} + +func TestUpdateDomainTypeIssuesMainToMainWithDuplicateDocrootFailsClosed(t *testing.T) { + pd := migrationData{ + SrcDomains: []model.Domain{{Name: "example.com", Type: model.Main}}, + DestDomains: []model.Domain{{Name: "example.com", Type: model.Main}}, + // Two docroot rows for the same canonical domain: uniqueDestDocrootEntry + // collapses this to hasDoc=false, so the carve-out must NOT apply. + DestDocroots: []cpanel.DomainDataEntry{ + {Domain: "example.com", DocumentRoot: "/home/dest/public_html", Type: "main_domain"}, + {Domain: "Example.COM", DocumentRoot: "/home/dest/public_html/alias", Type: "main_domain"}, + }, + } + updateDomainTypeIssuesForUses(&pd, []selectedDomainUse{{Domain: "example.com", Flow: "web", Item: "/home/src/example.com"}}) + issue, ok := domainTypeIssue(pd, "example.com") + if !ok { + t.Fatalf("type issue missing for main→main with duplicate destination docroots: %+v", pd.DomainTypeIssues) + } + if !issue.BlockWeb || !issue.BlockDBConfig { + t.Fatalf("main→main with duplicate destination docroots should fail closed: %+v", issue) + } +} + func TestUpdateDomainTypeIssuesUsesCanonicalIdentity(t *testing.T) { pd := migrationData{ SrcDomains: []model.Domain{{Name: "Example.COM", Type: model.Addon}}, diff --git a/internal/migrate/mailbox_progress.go b/internal/migrate/mailbox_progress.go index 71ebb2b6..2d702ed4 100644 --- a/internal/migrate/mailbox_progress.go +++ b/internal/migrate/mailbox_progress.go @@ -40,11 +40,11 @@ func (m *mailboxProgress) Add(n int64) { m.prog.Add(n) } // notes what changed both on screen and in the report file, then opens a fresh // progress line for the continuation. Implements maildir.RescanReporter. func (m *mailboxProgress) Rescan(failedBatch, totalBatches, vanished, appeared int) { - m.prog.Replace(itemStr(m.log, "~", m.email, "%s — batch %d/%d changed on source", + m.prog.Replace(itemStr(m.log, "~", m.email, "%s: batch %d/%d changed on source", m.log.Yellow("re-scanning"), failedBatch, totalBatches)) - m.log.Detail("source changed mid-copy: %d message(s) vanished, %d appeared — continuing with the missing blocks", vanished, appeared) + m.log.Detail("source changed mid-copy: %d message(s) vanished, %d appeared, continuing with the missing blocks", vanished, appeared) if m.rep != nil { - m.rep.FileOnlyf(" [re-scan] %s — batch %d/%d changed on source (%d vanished, %d appeared); continuing with the missing blocks", + m.rep.FileOnlyf(" [re-scan] %s: batch %d/%d changed on source (%d vanished, %d appeared); continuing with the missing blocks", m.email, failedBatch, totalBatches, vanished, appeared) } m.prog = m.log.NewInlineProgress(itemPrefix(m.log, "→", m.email), 0) diff --git a/internal/migrate/mailbox_progress_test.go b/internal/migrate/mailbox_progress_test.go index 838e4f23..e8ea6b3e 100644 --- a/internal/migrate/mailbox_progress_test.go +++ b/internal/migrate/mailbox_progress_test.go @@ -19,7 +19,7 @@ func TestMailboxProgressRescanRendersInterruption(t *testing.T) { var buf bytes.Buffer log := logx.NewTo(&buf, 0) - mp := newMailboxProgress(log, nil, "info@rsgarage.eu") + mp := newMailboxProgress(log, nil, "info@example.net") mp.SetTotal(1000) mp.Add(500) mp.Rescan(3, 9, 2, 1) // batch 3/9 failed; 2 vanished, 1 appeared @@ -27,7 +27,7 @@ func TestMailboxProgressRescanRendersInterruption(t *testing.T) { out := buf.String() for _, want := range []string{ - "info@rsgarage.eu", + "info@example.net", "re-scanning", "batch 3/9 changed on source", "2 message(s) vanished, 1 appeared", diff --git a/internal/migrate/pipeline_test.go b/internal/migrate/pipeline_test.go index 0b88d7bf..d1e20782 100644 --- a/internal/migrate/pipeline_test.go +++ b/internal/migrate/pipeline_test.go @@ -80,10 +80,10 @@ func TestScopeLabel(t *testing.T) { {true, false, true, "", "", "mail + databases"}, {false, true, true, "", "", "web files + databases"}, // --domain suffix appended to the base scope. - {true, false, false, "tissolution.it", "", "mail only (domain: tissolution.it)"}, - {true, true, false, "tissolution.it", "", "mail + web files (domain: tissolution.it)"}, + {true, false, false, "my.example.com", "", "mail only (domain: my.example.com)"}, + {true, true, false, "my.example.com", "", "mail + web files (domain: my.example.com)"}, // --mailbox is mail-only with its own suffix. - {true, false, false, "", "info@tissolution.it", "mail only (mailbox: info@tissolution.it)"}, + {true, false, false, "", "info@my.example.com", "mail only (mailbox: info@my.example.com)"}, } for _, c := range cases { if got := scopeLabel(c.doMail, c.doFile, c.doDB, c.onlyDomain, c.onlyMailbox); got != c.want { @@ -108,12 +108,12 @@ func TestApplyCommand(t *testing.T) { {false, true, true, "", "", "--apply --file --db"}, {true, true, false, "", "", "--apply --mail --file"}, // --domain default is docroot+mail: bare --apply --domain X (no kind flag). - {true, true, false, "tissolution.it", "", "--apply --domain tissolution.it"}, + {true, true, false, "my.example.com", "", "--apply --domain my.example.com"}, // Narrower than the default: emit the single kind flag. - {true, false, false, "tissolution.it", "", "--apply --mail --domain tissolution.it"}, - {false, true, false, "tissolution.it", "", "--apply --file --domain tissolution.it"}, + {true, false, false, "my.example.com", "", "--apply --mail --domain my.example.com"}, + {false, true, false, "my.example.com", "", "--apply --file --domain my.example.com"}, // --mailbox is self-describing and mail-only. - {true, false, false, "", "info@tissolution.it", "--apply --mailbox info@tissolution.it"}, + {true, false, false, "", "info@my.example.com", "--apply --mailbox info@my.example.com"}, } for _, c := range cases { if got := applyCommand(c.doMail, c.doFile, c.doDB, c.onlyDomain, c.onlyMailbox); got != c.want { diff --git a/internal/migrate/runner.go b/internal/migrate/runner.go index 0ef6f4d7..7378be06 100644 --- a/internal/migrate/runner.go +++ b/internal/migrate/runner.go @@ -18,6 +18,7 @@ import ( "github.com/tis24dev/cPanel_self-migration/internal/config" "github.com/tis24dev/cPanel_self-migration/internal/dbmig" "github.com/tis24dev/cPanel_self-migration/internal/domainname" + "github.com/tis24dev/cPanel_self-migration/internal/events" "github.com/tis24dev/cPanel_self-migration/internal/logx" "github.com/tis24dev/cPanel_self-migration/internal/model" "github.com/tis24dev/cPanel_self-migration/internal/report" @@ -64,6 +65,8 @@ type Options struct { OnlyMailbox string OutputDir string // where artifacts (logs) are written Now time.Time + RunID string // optional; generated if empty + Events events.Emitter // optional; zero value = no events emitted } // timeFormat is the timestamp layout used in the log artifacts. @@ -161,9 +164,6 @@ func createLogFile(outputDir, name string) (*atomicLogFile, string, error) { if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { return nil, "", fmt.Errorf("logs path %s is not a real directory", dir) } - if err := os.Chmod(dir, 0o700); err != nil { - return nil, "", fmt.Errorf("chmod logs dir %s: %w", dir, err) - } root, err := os.OpenRoot(dir) if err != nil { return nil, "", fmt.Errorf("open logs dir %s: %w", dir, err) @@ -183,6 +183,21 @@ func createLogFile(outputDir, name string) (*atomicLogFile, string, error) { return &atomicLogFile{File: f, root: root, tmp: tmp, name: name, dir: dir}, filepath.Join(dir, name), nil } +// emitEvent is a convenience wrapper for sending events with host context. +func emitEvent(em events.Emitter, runID string, srcRef, destRef hostRef, phase events.Phase, etype events.EventType, level events.Level, msg string, data any) { + em.Send(events.Event{ + RunID: runID, + TS: time.Now(), + Level: level, + Phase: phase, + Type: etype, + Message: msg, + Source: events.HostRef{IP: srcRef.IP, User: srcRef.User}, + Dest: events.HostRef{IP: destRef.IP, User: destRef.User}, + Data: data, + }) +} + // Run executes the migration according to opts. cfg must have a valid source; // if the destination is not configured, Run stops after the source analysis. func Run(ctx context.Context, cfg config.Config, opts Options) error { @@ -224,6 +239,16 @@ func Run(ctx context.Context, cfg config.Config, opts Options) error { // steps are numbered, so a mail-only run shows [1..6] and a file-only run // shows its own steps, with no misleading "skipped" noise for the other // flow. Domain creation is shared (needed by both mail and files). + runID := opts.RunID + if runID == "" { + runID = events.NewRunID(opts.Now) + } + // Propagate the RESOLVED run ID to runApply, which emits the apply phase + // events from opts (PR 7C), otherwise those events would carry the raw, + // possibly-empty flag value. + opts.RunID = runID + em := opts.Events + plan := buildPipeline(opts, destConfigured) log := logx.New(plan.total) mode := "DRY-RUN (no changes)" @@ -231,16 +256,21 @@ func Run(ctx context.Context, cfg config.Config, opts Options) error { mode = "APPLY (writes to destination)" } scope := scopeLabel(doMail, doFile, doDB, opts.OnlyDomain, opts.OnlyMailbox) - log.Plain("cpanel-self-migration — mode: %s — scope: %s", mode, scope) + log.Plain("cpanel-self-migration, mode: %s, scope: %s", mode, scope) log.Plain(" SOURCE: %s (read-only)", srcRef) if destConfigured { log.Plain(" DEST : %s", destRef) } else { - log.Plain(" DEST : (not configured — source-only analysis)") + log.Plain(" DEST : (not configured, source-only analysis)") } log.Plain("") + emitEvent(em, runID, srcRef, destRef, "", events.EventRunStarted, events.LevelInfo, + fmt.Sprintf("migration started, mode: %s, scope: %s", mode, scope), nil) + // ---- STEP: connect to both servers ---- + emitEvent(em, runID, srcRef, destRef, events.PhaseConnect, events.EventPhaseStarted, events.LevelInfo, + "Connecting to servers", nil) if destConfigured { log.Step("Connecting to source and destination ...") log.Detail("opening SSH connections ...") @@ -250,9 +280,13 @@ func Run(ctx context.Context, cfg config.Config, opts Options) error { } pool, err := sshx.DialBoth(ctx, cfg, "") if err != nil { + emitEvent(em, runID, srcRef, destRef, events.PhaseConnect, events.EventPhaseFailed, events.LevelError, + fmt.Sprintf("connect failed: %v", err), nil) return err } - defer pool.Close() + defer func() { _ = pool.Close() }() + emitEvent(em, runID, srcRef, destRef, events.PhaseConnect, events.EventPhaseCompleted, events.LevelInfo, + "connected", nil) if destConfigured { log.OK("connected to source and destination") } else { @@ -261,6 +295,8 @@ func Run(ctx context.Context, cfg config.Config, opts Options) error { // ---- STEP: analyze source ~/mail (read-only) — only when mail is in scope. if doMail { + emitEvent(em, runID, srcRef, destRef, events.PhaseAnalyzeMail, events.EventPhaseStarted, events.LevelInfo, + "Analyzing source mailboxes", nil) log.Step("Analyzing the SOURCE mailboxes (~/mail) ...") // Live scan as ONE inline row: action "~/mail scan" on the left, a live // "N mailboxes" counter on the right while the (single) ~/mail walk runs, @@ -270,6 +306,8 @@ func Run(ctx context.Context, cfg config.Config, opts Options) error { rep, err := analyze(ctx, pool.Src, srcRef.String(), date, func() { prog.Add(1) }) if err != nil { prog.Finish() + emitEvent(em, runID, srcRef, destRef, events.PhaseAnalyzeMail, events.EventPhaseFailed, events.LevelError, + fmt.Sprintf("analyze mail failed: %v", err), nil) return err } af, analysisPath, err := createLogFile(opts.OutputDir, "mail_analysis.log") @@ -292,8 +330,10 @@ func Run(ctx context.Context, cfg config.Config, opts Options) error { } nd, nm, na, no := rep.Totals() prog.Replace(itemStr(log, "✓", "~/mail scan", - "%s — %d mailbox(es) in %d domain(s) (%d active, %d orphan)", log.Green("done"), nm, nd, na, no)) + "%s: %d mailbox(es) in %d domain(s) (%d active, %d orphan)", log.Green("done"), nm, nd, na, no)) log.OK("wrote %s", analysisPath) + emitEvent(em, runID, srcRef, destRef, events.PhaseAnalyzeMail, events.EventPhaseCompleted, events.LevelInfo, + fmt.Sprintf("analyzed %d mailbox(es) in %d domain(s)", nm, nd), map[string]any{"domains": nd, "mailboxes": nm, "active": na, "orphan": no}) } if !destConfigured { @@ -317,7 +357,9 @@ func Run(ctx context.Context, cfg config.Config, opts Options) error { } } log.Plain("") - log.Plain("Destination not configured — stopped after source analysis.") + log.Plain("Destination not configured, stopped after source analysis.") + emitEvent(em, runID, srcRef, destRef, "", events.EventRunCompleted, events.LevelInfo, + "source-only analysis complete", nil) return nil } @@ -325,10 +367,16 @@ func Run(ctx context.Context, cfg config.Config, opts Options) error { // gatherData renders its own live progress (a per-operation bar that ends in a // persistent "✓ inventory read — ..." line), so no static header is printed // here — that would just blink with a frozen cursor during the reads. + emitEvent(em, runID, srcRef, destRef, events.PhaseGatherData, events.EventPhaseStarted, events.LevelInfo, + "Gathering inventory from source and destination", nil) pd, err := gatherData(ctx, pool, log, doMail, doFile, doDB) if err != nil { + emitEvent(em, runID, srcRef, destRef, events.PhaseGatherData, events.EventPhaseFailed, events.LevelError, + fmt.Sprintf("gather data failed: %v", err), nil) return err } + emitEvent(em, runID, srcRef, destRef, events.PhaseGatherData, events.EventPhaseCompleted, events.LevelInfo, + "inventory gathered", nil) // Narrow the working-set to --domain / --mailbox (if set) BEFORE coverage, // domain-create planning, summaries, and compare/apply all read it. This one // call cascades to every phase; it validates the target exists on the source @@ -353,31 +401,55 @@ func Run(ctx context.Context, cfg config.Config, opts Options) error { // only. compareDryRun reports BOTH the domain plan (present / to create) and the // per-mailbox comparison, so the step title names both. ---- if doMail { + emitEvent(em, runID, srcRef, destRef, events.PhaseCompareMail, events.EventPhaseStarted, events.LevelInfo, + "Comparing mailboxes", nil) log.Step("Comparing SOURCE and DESTINATION domains and mailboxes (read-only) ...") compareDryRun(ctx, &comparator{src: pool.Src, dest: pool.Dest}, pd, log, opts.MirrorMail) + emitEvent(em, runID, srcRef, destRef, events.PhaseCompareMail, events.EventPhaseCompleted, events.LevelInfo, + "mailbox comparison done", nil) } // ---- STEPS: analyze + compare web files (read-only) — file scope only. ---- if doFile { + emitEvent(em, runID, srcRef, destRef, events.PhaseAnalyzeFiles, events.EventPhaseStarted, events.LevelInfo, + "Analyzing web files", nil) log.Step("Analyzing the SOURCE web files (docroots) ...") if err := analyzeWebFiles(ctx, pool, pd, log, opts.OutputDir, srcRef.String(), date, false); err != nil { + emitEvent(em, runID, srcRef, destRef, events.PhaseAnalyzeFiles, events.EventPhaseFailed, events.LevelError, + fmt.Sprintf("analyze web files failed: %v", err), nil) return err } + emitEvent(em, runID, srcRef, destRef, events.PhaseAnalyzeFiles, events.EventPhaseCompleted, events.LevelInfo, + "web file analysis done", nil) + emitEvent(em, runID, srcRef, destRef, events.PhaseCompareFiles, events.EventPhaseStarted, events.LevelInfo, + "Comparing web files", nil) log.Step("Comparing SOURCE and DESTINATION web files (read-only) ...") compareWebFiles(ctx, pool, pd, log) + emitEvent(em, runID, srcRef, destRef, events.PhaseCompareFiles, events.EventPhaseCompleted, events.LevelInfo, + "web file comparison done", nil) } // ---- STEPS: analyze + compare databases (read-only) — db scope only. ---- if doDB { + emitEvent(em, runID, srcRef, destRef, events.PhaseAnalyzeDB, events.EventPhaseStarted, events.LevelInfo, + "Analyzing databases", nil) overrides := dbOverrides(cfg) log.Step("Analyzing the SOURCE databases ...") if err := analyzeDBs(ctx, pd, log, opts.OutputDir, srcRef.String(), date, overrides, false); err != nil { + emitEvent(em, runID, srcRef, destRef, events.PhaseAnalyzeDB, events.EventPhaseFailed, events.LevelError, + fmt.Sprintf("analyze databases failed: %v", err), nil) return err } + emitEvent(em, runID, srcRef, destRef, events.PhaseAnalyzeDB, events.EventPhaseCompleted, events.LevelInfo, + "database analysis done", nil) + emitEvent(em, runID, srcRef, destRef, events.PhaseCompareDB, events.EventPhaseStarted, events.LevelInfo, + "Comparing databases", nil) log.Step("Comparing SOURCE and DESTINATION databases (read-only) ...") compareDBs(pd, log, overrides) + emitEvent(em, runID, srcRef, destRef, events.PhaseCompareDB, events.EventPhaseCompleted, events.LevelInfo, + "database comparison done", nil) } // ---- Apply (or stop here in dry-run). ---- @@ -385,10 +457,23 @@ func Run(ctx context.Context, cfg config.Config, opts Options) error { log.Plain("") log.Plain("DRY-RUN complete: no changes were made to either server.") log.Plain("Re-run with %s to perform the migration (%s).", applyCommand(doMail, doFile, doDB, opts.OnlyDomain, opts.OnlyMailbox), scope) + emitEvent(em, runID, srcRef, destRef, "", events.EventRunCompleted, events.LevelInfo, + "dry-run complete", nil) return nil } - return runApply(ctx, pool, cfg, pd, opts, log, srcRef.String(), destRef.String(), date) + err = runApply(ctx, pool, cfg, pd, opts, log, srcRef.String(), destRef.String(), date) + if err != nil { + // run-level failure Message carries err.Error() verbatim; dev's error + // strings are report-line reasons with no embedded secrets (the UAPI + // password lives in args, not error text), so no scrub is needed. + emitEvent(em, runID, srcRef, destRef, "", events.EventRunFailed, events.LevelError, + err.Error(), nil) + } else { + emitEvent(em, runID, srcRef, destRef, "", events.EventRunCompleted, events.LevelInfo, + "run completed", nil) + } + return err } // pipeline describes the active steps for a run (only used for the [n/N] count). diff --git a/internal/migrate/scope_filter_test.go b/internal/migrate/scope_filter_test.go index 1e93c84f..c03bee07 100644 --- a/internal/migrate/scope_filter_test.go +++ b/internal/migrate/scope_filter_test.go @@ -56,7 +56,7 @@ func TestSplitMailbox(t *testing.T) { local, domain string ok bool }{ - {"info@tissolution.it", "info", "tissolution.it", true}, + {"info@my.example.com", "info", "my.example.com", true}, {"a@b@c", "a@b", "c", true}, // splits on the FINAL @ {"noat", "", "", false}, {"@domain.tld", "", "", false}, diff --git a/internal/migrate/selected_domains.go b/internal/migrate/selected_domains.go index 1526d397..993d45ad 100644 --- a/internal/migrate/selected_domains.go +++ b/internal/migrate/selected_domains.go @@ -113,7 +113,7 @@ func selectedApplyDomainUses(pd migrationData, opts Options, overrides map[strin for _, cfg := range it.Configs { entry, ok := srcDocrootContaining(pd, cfg.ConfigPath) if !ok { - logx.Debug("selectedApplyDomainUses: db %s config %s did not resolve to a source docroot — not counted toward domain coverage", it.SrcDB, cfg.ConfigPath) + logx.Debug("selectedApplyDomainUses: db %s config %s did not resolve to a source docroot, not counted toward domain coverage", it.SrcDB, cfg.ConfigPath) continue } add(entry.Domain, "db", fmt.Sprintf("%s for %s", cfg.ConfigPath, it.SrcDB)) diff --git a/internal/migrate/selected_domains_test.go b/internal/migrate/selected_domains_test.go index 05ffbcb3..b3e0d2b0 100644 --- a/internal/migrate/selected_domains_test.go +++ b/internal/migrate/selected_domains_test.go @@ -297,7 +297,7 @@ func TestCompareDryRunReportsAddonLabelCollisionAsBlocked(t *testing.T) { if !strings.Contains(out, "info@my-site.example") || !strings.Contains(out, "sales@mysite.example") { t.Fatalf("dry-run should surface blocked mailbox rows without reading destination stats:\n%s", out) } - if strings.Contains(out, "MISSING on dest — will create (addon)") { + if strings.Contains(out, "MISSING on dest, will create (addon)") { t.Fatalf("blocked collision domains must not be previewed as creatable addons:\n%s", out) } if strings.Contains(out, "TO MIGRATE") { diff --git a/internal/migrate/source_only_test.go b/internal/migrate/source_only_test.go index d00472f7..2a50e2b2 100644 --- a/internal/migrate/source_only_test.go +++ b/internal/migrate/source_only_test.go @@ -70,7 +70,7 @@ exit 0 for _, want := range []string{ "# Dest : not configured (source-only analysis)", "DOMAIN: main.example [READY]", - "- dest docroot: (not configured — source-only analysis)", + "- dest docroot: (not configured, source-only analysis)", "TOTAL DOCROOTS : 1", } { if !strings.Contains(out, want) { diff --git a/internal/migrate/verify.go b/internal/migrate/verify.go index 047f5887..83562768 100644 --- a/internal/migrate/verify.go +++ b/internal/migrate/verify.go @@ -50,13 +50,13 @@ func classifyVerify(s, d maildir.BoxStats, srcErr, destErr error) verdict { return verdict{vConsistent, "OK", ""} case s.UIDValidity != d.UIDValidity && s.MsgCount == d.MsgCount: return verdict{vUIDMismatch, "UIDVALIDITY", - "same count but different UIDVALIDITY — re-sync to align (IMAP clients will re-download)"} + "same count but different UIDVALIDITY, re-sync to align (IMAP clients will re-download)"} case d.MsgCount < s.MsgCount: return verdict{vIncomplete, "INCOMPLETE", - fmt.Sprintf("dest is missing %d message(s) — re-run --apply to copy them", s.MsgCount-d.MsgCount)} + fmt.Sprintf("dest is missing %d message(s), re-run --apply to copy them", s.MsgCount-d.MsgCount)} default: // d.MsgCount > s.MsgCount return verdict{vDestAhead, "DEST AHEAD", - fmt.Sprintf("dest has %d message(s) NOT on source — --apply will NOT change this (extra mail lives only on dest, e.g. Trash/INBOX)", d.MsgCount-s.MsgCount)} + fmt.Sprintf("dest has %d message(s) NOT on source, --apply will NOT change this (extra mail lives only on dest, e.g. Trash/INBOX)", d.MsgCount-s.MsgCount)} } } @@ -196,7 +196,7 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo } for _, m := range pd.Mailboxes { if ctx.Err() != nil { - log.Warn("interrupted — integrity check stopped") + log.Warn("interrupted, integrity check stopped") return 0, ctx.Err() } email := m.Email() @@ -213,17 +213,17 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo if domainFailed(pd, m.Domain) { domainFailedSkip++ rep.LogScreenFile( - itemStr(log, "→", email, "%s — destination domain creation failed earlier; mailbox not migrated/verified", log.Yellow("SKIP")), + itemStr(log, "→", email, "%s: destination domain creation failed earlier; mailbox not migrated/verified", log.Yellow("SKIP")), report.VerifySkipLine(email, "domain '"+m.Domain+"' creation failed earlier; counted under failed domains")) } else if reason, blocked := domainBlocked(pd, m.Domain); blocked { domainBlockedSkip++ rep.LogScreenFile( - itemStr(log, "→", email, "%s — %s", log.Yellow("SKIP"), reason), + itemStr(log, "→", email, "%s: %s", log.Yellow("SKIP"), reason), report.VerifySkipLine(email, reason+"; counted under blocked domains")) } else { absentUnverified++ rep.LogScreenFile( - itemStr(log, "~", email, "%s — destination domain absent after the domain step (not migrated, not verified)", log.Red("UNVERIFIED")), + itemStr(log, "~", email, "%s: destination domain absent after the domain step (not migrated, not verified)", log.Red("UNVERIFIED")), report.VerifyDiffLine(email, "UNVERIFIED", "", "", "", "", "destination domain '"+m.Domain+"' absent after the domain step")) } continue @@ -242,9 +242,9 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo // applyMailboxes over the same pd.Mailboxes (runApply). if m.Hash == "" { noHashSkip++ - logx.Debug("verify %s: SKIP — no source password hash; not applied (counted under unverified at apply)", email) + logx.Debug("verify %s: SKIP: no source password hash; not applied (counted under unverified at apply)", email) rep.LogScreenFile( - itemStr(log, "→", email, "%s — no source password hash; account/password not applied (counted under unverified)", log.Yellow("SKIP")), + itemStr(log, "→", email, "%s: no source password hash; account/password not applied (counted under unverified)", log.Yellow("SKIP")), report.VerifySkipLine(email, "no source password hash; account/password not applied; counted under unverified at apply")) continue } @@ -253,7 +253,7 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo absentUnverified++ reason := destDomainResolutionIssue(pd, m.Domain) rep.LogScreenFile( - itemStr(log, "~", email, "%s — %s (not migrated, not verified)", log.Red("UNVERIFIED"), reason), + itemStr(log, "~", email, "%s: %s (not migrated, not verified)", log.Red("UNVERIFIED"), reason), report.VerifyDiffLine(email, "UNVERIFIED", "", "", "", "", reason)) continue } @@ -262,7 +262,7 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo if err1 != nil || err2 != nil { logx.Debug("verify %s: UNREADABLE (srcErr=%v destErr=%v)", email, err1, err2) rep.LogScreenFile( - itemStr(log, "~", email, "%s — could not read one side", log.Red("UNREADABLE")), + itemStr(log, "~", email, "%s: could not read one side", log.Red("UNREADABLE")), report.VerifyDiffLine(email, "UNREADABLE", "", "", "", "", "could not read one side")) bump(classifyMailVerifyImpact(vUnreadable, contentClean)) continue @@ -346,9 +346,9 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo // Default tier, content could not be byte-verified: honest yellow OK with a // note (metadata matched), never a green "content verified". rep.LogScreenFile( - itemStr(log, "~", email, "%s (msg=%d uid=%s; bodies NOT byte-verified — %s)", log.Yellow("OK"), mv.totalCount, orQ(mv.inboxUV), mailContentNote), + itemStr(log, "~", email, "%s (msg=%d uid=%s; bodies NOT byte-verified, %s)", log.Yellow("OK"), mv.totalCount, orQ(mv.inboxUV), mailContentNote), report.VerifyOKLine(email, strconv.Itoa(mv.totalCount), mv.inboxUV)) - rep.FileOnlyf(" NOTE: message bodies were not byte-verified — %s. Per-folder count + UIDVALIDITY matched. Use --deep-verify for per-message content hashes.", mailContentNote) + rep.FileOnlyf(" NOTE: message bodies were not byte-verified, %s. Per-folder count + UIDVALIDITY matched. Use --deep-verify for per-message content hashes.", mailContentNote) case !deep: // Default tier, content fingerprint matched: bodies verified at tree level. rep.LogScreenFile( @@ -370,7 +370,7 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo if mv.kind == vConsistent { if content == contentUnverified { rep.LogScreenFile( - itemStr(log, "~", email, "%s — could not read message bodies for the deep check (re-run --apply --deep-verify)", log.Red("UNVERIFIED")), + itemStr(log, "~", email, "%s: could not read message bodies for the deep check (re-run --apply --deep-verify)", log.Red("UNVERIFIED")), report.VerifyDiffLine(email, "UNVERIFIED", strconv.Itoa(mv.totalCount), mv.inboxUV, "", "", "deep content check could not read one side")) continue } @@ -379,12 +379,12 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo // differ (same folder counts) but the default rolls it to one verdict // without naming the message(s) — that detail is --deep-verify's job. rep.LogScreenFile( - itemStr(log, "~", email, "%s — message bodies differ at matching folder counts (run --deep-verify to localize the message)", log.Red("CONTENT")), + itemStr(log, "~", email, "%s: message bodies differ at matching folder counts (run --deep-verify to localize the message)", log.Red("CONTENT")), report.VerifyDiffLine(email, "CONTENT", strconv.Itoa(mv.totalCount), mv.inboxUV, "", "", "message bodies differ (run --deep-verify to localize the message(s))")) continue } rep.LogScreenFile( - itemStr(log, "~", email, "%s — %d message(s) corrupted, %d missing (same folder counts)", + itemStr(log, "~", email, "%s: %d message(s) corrupted, %d missing (same folder counts)", log.Red("CONTENT"), len(cChanged), len(cMissing)), report.VerifyDiffLine(email, "CONTENT", strconv.Itoa(mv.totalCount), mv.inboxUV, "", "", "message bodies differ")) reportContentDiffs(rep, cMissing, cChanged) @@ -398,7 +398,7 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo // same finding through its per-message path in the folder-diff block below. if mv.kind == vDestAhead && fingerprintDiff { rep.LogScreenFile( - itemStr(log, "~", email, "%s — source-present message bodies differ; the destination also holds extra mail (run --deep-verify to localize)", log.Red("CONTENT")), + itemStr(log, "~", email, "%s: source-present message bodies differ; the destination also holds extra mail (run --deep-verify to localize)", log.Red("CONTENT")), report.VerifyDiffLine(email, "CONTENT", strconv.Itoa(mv.totalCount), mv.inboxUV, "", "", "source-present message bodies differ; destination also holds extra dest-only mail (run --deep-verify to localize)")) for _, fd := range mv.folderDiffs { rep.FileOnlyf(" %s", fd) @@ -415,7 +415,7 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo brief := strings.Join(firstN(mv.diffNames, 4), ", ") note := fmt.Sprintf("%d folder(s) differ: %s", len(mv.folderDiffs), brief) rep.LogScreenFile( - itemStr(log, "~", email, "%s — %s", label, note), + itemStr(log, "~", email, "%s: %s", label, note), report.VerifyDiffLine(email, mv.label, strconv.Itoa(mv.totalCount), mv.inboxUV, "", "", note)) for _, fd := range mv.folderDiffs { rep.FileOnlyf(" %s", fd) @@ -426,7 +426,7 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo } else if content == contentUnverified { rep.FileOnlyf(" content: deep check could not read one side (unverified)") } else if mailContentNote != "" { - rep.FileOnlyf(" content: source-present bodies not byte-verified — %s", mailContentNote) + rep.FileOnlyf(" content: source-present bodies not byte-verified, %s", mailContentNote) } } @@ -464,31 +464,31 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo // file via the reporter — kept in sync, no double-print on screen). log.Warn("%d mailbox(es) differ:", total) if incomplete > 0 { - log.Detail("%d INCOMPLETE (a folder is missing messages) — re-run --apply to copy them", incomplete) - rep.FileOnlyf(" %d INCOMPLETE: a folder is missing messages — re-run --apply to copy them.", incomplete) + log.Detail("%d INCOMPLETE (a folder is missing messages), re-run --apply to copy them", incomplete) + rep.FileOnlyf(" %d INCOMPLETE: a folder is missing messages, re-run --apply to copy them.", incomplete) } if uidmismatch > 0 { - log.Detail("%d UIDVALIDITY mismatch — re-run --apply to re-sync", uidmismatch) - rep.FileOnlyf(" %d UIDVALIDITY mismatch — re-run --apply to re-sync.", uidmismatch) + log.Detail("%d UIDVALIDITY mismatch, re-run --apply to re-sync", uidmismatch) + rep.FileOnlyf(" %d UIDVALIDITY mismatch, re-run --apply to re-sync.", uidmismatch) } if ahead > 0 { - log.Detail("%d DEST AHEAD (extra mail only on dest) — re-run --apply will NOT change this", ahead) + log.Detail("%d DEST AHEAD (extra mail only on dest), re-run --apply will NOT change this", ahead) rep.FileOnlyf(" %d DEST AHEAD: extra mail exists only on dest (e.g. Trash/INBOX); --apply will NOT remove it.", ahead) } if contentBad > 0 { - log.Detail("%d CONTENT (message bodies corrupted/replaced) — re-run --apply --full to re-copy", contentBad) - rep.FileOnlyf(" %d CONTENT: message bodies differ (corruption/replacement) — re-run --apply --full to re-copy.", contentBad) + log.Detail("%d CONTENT (message bodies corrupted/replaced), re-run --apply --full to re-copy", contentBad) + rep.FileOnlyf(" %d CONTENT: message bodies differ (corruption/replacement), re-run --apply --full to re-copy.", contentBad) } if unverified > 0 { - log.Detail("%d UNVERIFIED (deep content check could not read message bodies) — re-run --apply --deep-verify", unverified) - rep.FileOnlyf(" %d UNVERIFIED: the deep content check could not read one side; not certified — re-run --apply --deep-verify.", unverified) + log.Detail("%d UNVERIFIED (deep content check could not read message bodies), re-run --apply --deep-verify", unverified) + rep.FileOnlyf(" %d UNVERIFIED: the deep content check could not read one side; not certified, re-run --apply --deep-verify.", unverified) } if unreadable > 0 { - log.Detail("%d UNREADABLE — could not compare", unreadable) + log.Detail("%d UNREADABLE, could not compare", unreadable) rep.FileOnlyf(" %d UNREADABLE: could not read one side.", unreadable) } if absentUnverified > 0 { - log.Detail("%d UNVERIFIED (selected destination domain absent after the domain step) — fix the domain and re-run --apply", absentUnverified) + log.Detail("%d UNVERIFIED (selected destination domain absent after the domain step), fix the domain and re-run --apply", absentUnverified) rep.FileOnlyf(" %d UNVERIFIED: selected destination domain absent after the domain step; not migrated, not verified.", absentUnverified) } if domainSkipped > 0 { @@ -503,7 +503,7 @@ func verify(ctx context.Context, pool *sshx.Pool, pd migrationData, log *logx.Lo // defaultMailContentMsgCap bounds how many messages a mailbox may hold before the // DEFAULT body fingerprint is skipped (a soft "not byte-verified" note, never a silent -// OK): the fingerprint forks sha256sum per message on both hosts, so an enormous mailbox +// OK): the fingerprint runs sha256sum per message on both hosts, so an enormous mailbox // would hash for a very long time on every run. --deep-verify is the opt-in path above // this. 200k messages is generous for a single account. const defaultMailContentMsgCap = 200_000 @@ -626,7 +626,7 @@ func classifyMailbox(src, dest map[string]maildir.FolderStats) mailboxVerdict { continue } mv.diffNames = append(mv.diffNames, f) - mv.folderDiffs = append(mv.folderDiffs, fmt.Sprintf("%s: %s — SRC(msg=%d uv=%s) DEST(msg=%d uv=%s)", + mv.folderDiffs = append(mv.folderDiffs, fmt.Sprintf("%s: %s: SRC(msg=%d uv=%s) DEST(msg=%d uv=%s)", f, v.label, s.MsgCount, orQ(s.UIDValidity), d.MsgCount, orQ(d.UIDValidity))) if kindSeverity(v.kind) > kindSeverity(mv.kind) { mv.kind = v.kind diff --git a/internal/migrate/webfiles_common.go b/internal/migrate/webfiles_common.go index 11b82858..5ce3abcb 100644 --- a/internal/migrate/webfiles_common.go +++ b/internal/migrate/webfiles_common.go @@ -211,7 +211,7 @@ func applyGatherResults(items []webfiles.WebPlanItem, results map[string]webfile it.Notes = append(it.Notes, "source docroot unreadable: "+it.SrcDocroot) case res.Count == 0: it.Skip = true - it.Notes = append(it.Notes, "source docroot empty — destination left untouched") + it.Notes = append(it.Notes, "source docroot empty, destination left untouched") default: it.SrcBytes = res.Bytes it.SrcFileCount = res.Count diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 00000000..67fe9497 --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,160 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +// Package redact is the single hardening choke-point for key-based secret +// redaction across cPSM. The redaction logic previously drifted across three +// copies (events, cpanel debug, cpanel cron) with different sensitive-key +// lists, and that drift caused a real leak: a live PrestaShop cron job +// authenticated with secure=, which slipped through because the cron +// list covered "secret" but not "secure". Unifying on ONE superset list means +// every caller redacts at least as much as the union of what any single copy +// ever redacted; adding a fragment here hardens every caller at once and no +// caller can quietly fall behind. +// +// Matching is a case-insensitive, space-trimmed SUBSTRING test, so variants +// like access_token, client_secret, DB_PASSWORD, MYSQL_PWD, x-csrf-token, +// session_id and secure=... are all caught. Over-redaction is an accepted, +// deliberate trade-off: a masked non-secret is harmless, a leaked credential +// is not. This package imports nothing project-local (leaf), so events and +// cpanel may depend on it with no import cycle. +package redact + +import ( + "bytes" + "encoding/json" + "fmt" + "slices" + "strings" +) + +// Placeholder is the value substituted for a redacted map/JSON value. The +// command-string redaction in cpanel/cron keeps its own "[REDACTED]" marker +// and is not governed by this constant; only the sensitive-key DECISION is +// centralized here. +const Placeholder = "" + +// sensitiveKeyFragments is the shared SUPERSET of name fragments that mark a +// key or variable name as secret-bearing. It is the union of the three former +// per-package lists: events/debug contributed token, secret, pass, key, auth, +// cred, cookie, session, bearer; cron additionally contributed pwd and secure. +// "secure" earns its place from a real incident (see the package doc). "pwd" +// catches MYSQL_PWD-style names. Do NOT add "user": as a map/JSON key it is a +// legitimate non-secret field (cron's flag regex handles a "--user" flag +// separately). It is unexported so no caller can mutate the canonical list and +// silently weaken every redactor: callers that need the raw fragments to build +// a regex call Fragments(), callers matching a single key call IsSensitiveKey. +var sensitiveKeyFragments = []string{ + "token", "secret", "pass", "pwd", "key", + "auth", "cred", "cookie", "session", "bearer", "secure", +} + +// Fragments returns a COPY of the shared sensitive-key fragment list, for +// callers that build their own matcher (e.g. cron's command-line regex) and +// must stay in lockstep with the key-based redactors. Returning a copy keeps +// the canonical list unmutable from outside the package. +func Fragments() []string { + return slices.Clone(sensitiveKeyFragments) +} + +// IsSensitiveKey reports whether a key names a value to redact: its lower-cased, +// space-trimmed form contains any sensitive fragment. +func IsSensitiveKey(k string) bool { + k = strings.ToLower(strings.TrimSpace(k)) + for _, sub := range sensitiveKeyFragments { + if strings.Contains(k, sub) { + return true + } + } + return false +} + +// RedactMap returns a deep copy of m with every sensitive key's non-empty +// value replaced by Placeholder, at any depth (nested maps and slices). A nil +// map returns nil; an empty ("") or nil sensitive value is left untouched (it +// carries no secret and its emptiness is diagnostically useful). +func RedactMap(m map[string]any) map[string]any { + if m == nil { + return nil + } + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = redactValue(k, v) + } + return out +} + +func redactValue(key string, v any) any { + if IsSensitiveKey(key) && !isEmptyValue(v) { + return Placeholder + } + switch val := v.(type) { + case map[string]any: + return RedactMap(val) + case []any: + result := make([]any, len(val)) + for i, item := range val { + result[i] = redactValue("", item) + } + return result + } + return v +} + +// isEmptyValue reports whether v carries no secret to hide: nil, or an empty +// string. Shared by the map and JSON paths (both treat "" / null as empty). +func isEmptyValue(v any) bool { + if v == nil { + return true + } + if s, ok := v.(string); ok && s == "" { + return true + } + return false +} + +// RedactJSON parses raw as JSON and returns a re-serialized copy in which the +// value of every sensitive key, at any depth, is replaced by Placeholder while +// the STRUCTURE is preserved (so a response shape stays inspectable). It never +// returns raw bytes: on invalid JSON, on a bare top-level scalar (no key to +// match, could itself be a secret), or on a re-marshal failure, it returns a +// short value-free "withheld" placeholder. Empty/null sensitive values are left +// as-is. HTML escaping is off so the body stays human-readable in logs. +// +// LIMITATION: redaction is key-based; a secret inlined into the VALUE of a +// non-secret field is not detected. +func RedactJSON(raw []byte) string { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return fmt.Sprintf("<%d bytes, not valid JSON; withheld>", len(raw)) + } + if _, isObj := v.(map[string]any); !isObj { + if _, isArr := v.([]any); !isArr { + return fmt.Sprintf("<%d bytes, top-level JSON scalar; withheld>", len(raw)) + } + } + redactInPlace(v) + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return fmt.Sprintf("<%d bytes; withheld (re-marshal failed)>", len(raw)) + } + return strings.TrimRight(buf.String(), "\n") +} + +func redactInPlace(v any) { + switch t := v.(type) { + case map[string]any: + for k, child := range t { + if IsSensitiveKey(k) && !isEmptyValue(child) { + t[k] = Placeholder + continue + } + redactInPlace(child) + } + case []any: + for _, child := range t { + redactInPlace(child) + } + } +} diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go new file mode 100644 index 00000000..b08b0135 --- /dev/null +++ b/internal/redact/redact_test.go @@ -0,0 +1,255 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package redact + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func mapToString(m map[string]any) string { + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + _ = enc.Encode(m) + return strings.TrimRight(buf.String(), "\n") +} + +func TestRedactMap(t *testing.T) { + tests := []struct { + name string + input map[string]any + contains []string + excludes []string + }{ + { + name: "password redacted", + input: map[string]any{"password": "secret123", "user": "admin"}, + contains: []string{"", "admin"}, + excludes: []string{"secret123"}, + }, + { + name: "token redacted", + input: map[string]any{"api_token": "tok_abc", "ip": "1.2.3.4"}, + contains: []string{"", "1.2.3.4"}, + excludes: []string{"tok_abc"}, + }, + { + name: "nested password", + input: map[string]any{"config": map[string]any{"db_password": "pw123"}}, + contains: []string{""}, + excludes: []string{"pw123"}, + }, + { + name: "key containing secret", + input: map[string]any{"client_secret": "s3cr3t"}, + contains: []string{""}, + excludes: []string{"s3cr3t"}, + }, + { + name: "auth header", + input: map[string]any{"authorization": "Bearer xyz"}, + contains: []string{""}, + excludes: []string{"Bearer xyz"}, + }, + { + name: "credential key", + input: map[string]any{"credentials": "user:pass"}, + contains: []string{""}, + excludes: []string{"user:pass"}, + }, + { + name: "safe keys unchanged", + input: map[string]any{"ip": "1.2.3.4", "user": "admin", "domain": "example.com"}, + contains: []string{"1.2.3.4", "admin", "example.com"}, + }, + { + name: "empty password preserved", + input: map[string]any{"password": ""}, + contains: []string{`"password":""`}, + }, + { + name: "nil password preserved", + input: map[string]any{"password": nil}, + contains: []string{`"password":null`}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := RedactMap(tt.input) + s := mapToString(got) + for _, want := range tt.contains { + if !strings.Contains(s, want) { + t.Errorf("result missing %q: %s", want, s) + } + } + for _, bad := range tt.excludes { + if strings.Contains(s, bad) { + t.Errorf("result contains secret %q: %s", bad, s) + } + } + }) + } +} + +func TestIsSensitiveKey(t *testing.T) { + sensitive := []string{ + "password", "Password", "DB_PASSWORD", + "token", "api_token", "cpanel_token", + "secret", "client_secret", + "ssh_pass", "passphrase", + "auth", "authorization", + "key", "api_key", "private_key", + "credential", "credentials", + "cookie", "session_cookie", + "session", "session_id", + "bearer", + } + for _, k := range sensitive { + if !IsSensitiveKey(k) { + t.Errorf("IsSensitiveKey(%q) = false, want true", k) + } + } + + safe := []string{ + "ip", "user", "domain", "port", "host", + "mailbox", "database", "docroot", "path", + "count", "size", "status", "version", + } + for _, k := range safe { + if IsSensitiveKey(k) { + t.Errorf("IsSensitiveKey(%q) = true, want false", k) + } + } +} + +func TestRedactJSON(t *testing.T) { + const secret = "ABCDEF-SUPER-SECRET-TOKEN-0123456789" + body := `{"result":{"status":1,"data":{"name":"cpsm_x","token":"` + secret + `","expires_at":1700000000,"create_time":1699999100}}}` + got := RedactJSON([]byte(body)) + if strings.Contains(got, secret) { + t.Fatalf("redacted output still contains the token secret: %s", got) + } + if !strings.Contains(got, Placeholder) { + t.Errorf("expected the redaction placeholder in %s", got) + } + for _, must := range []string{`"name":"cpsm_x"`, `"expires_at":1700000000`, `"create_time":1699999100`, `"status":1`} { + if !strings.Contains(got, must) { + t.Errorf("redaction dropped a non-secret field %q from %s", must, got) + } + } +} + +func TestRedactJSONSubstringKeysAndWhitespace(t *testing.T) { + const secret = "leak-me-if-you-can" + cases := []string{ + `{"access_token":"` + secret + `"}`, + `{"auth_token":"` + secret + `"}`, + `{"client_secret":"` + secret + `"}`, + `{"apikey":"` + secret + `"}`, + `{"x-csrf-token":"` + secret + `"}`, + `{"Bearer":"` + secret + `"}`, + `{"session_id":"` + secret + `"}`, + `{"sessionCookie":"` + secret + `"}`, + `{"token ":"` + secret + `"}`, + `{" PASSWORD":"` + secret + `"}`, + } + for _, body := range cases { + got := RedactJSON([]byte(body)) + if strings.Contains(got, secret) { + t.Fatalf("secret leaked for %s -> %s", body, got) + } + if !strings.Contains(got, Placeholder) { + t.Errorf("expected redaction for %s -> %s", body, got) + } + } +} + +func TestRedactJSONWithholdsTopLevelScalar(t *testing.T) { + for _, body := range []string{`"a-bare-secret-string"`, `12345`, `true`} { + got := RedactJSON([]byte(body)) + if strings.Contains(got, "secret") || strings.Contains(got, "12345") || strings.Contains(got, "true") { + t.Fatalf("top-level scalar echoed: %s -> %s", body, got) + } + if !strings.Contains(got, "withheld") { + t.Errorf("expected withheld for %s -> %s", body, got) + } + } +} + +func TestRedactJSONNestedAndArrays(t *testing.T) { + const secret = "nested-secret-value" + body := `{"a":[{"password":"` + secret + `","keep":1},{"x":2}],"b":{"api_token":"` + secret + `"}}` + got := RedactJSON([]byte(body)) + if strings.Contains(got, secret) { + t.Fatalf("nested/array secret leaked: %s", got) + } + if !strings.Contains(got, `"keep":1`) || !strings.Contains(got, `"x":2`) { + t.Errorf("redaction dropped non-secret fields: %s", got) + } +} + +func TestRedactJSONKeepsEmptyAndNull(t *testing.T) { + got := RedactJSON([]byte(`{"token":"","secret":null,"name":"n"}`)) + if !strings.Contains(got, `"token":""`) { + t.Errorf("empty token should be preserved: %s", got) + } + if !strings.Contains(got, `"secret":null`) { + t.Errorf("null secret should be preserved: %s", got) + } + if strings.Contains(got, Placeholder) { + t.Errorf("nothing to redact, but got a placeholder: %s", got) + } +} + +func TestRedactJSONWithholdsNonJSON(t *testing.T) { + raw := []byte("not json but maybe-a-secret=" + "hunter2") + got := RedactJSON(raw) + if strings.Contains(got, "hunter2") { + t.Fatalf("non-JSON input leaked raw bytes: %s", got) + } + if !strings.Contains(got, "withheld") { + t.Errorf("expected a withheld placeholder for non-JSON input, got %s", got) + } +} + +// TestFragmentsSupersetOfHistorical locks the anti-drift invariant +// that caused the PrestaShop secure= leak: the shared list must never drop a +// fragment that any of the three historical per-package lists carried. +func TestFragmentsSupersetOfHistorical(t *testing.T) { + historical := [][]string{ + {"token", "secret", "pass", "key", "auth", "cred", "cookie", "session", "bearer"}, // events + {"token", "secret", "pass", "key", "auth", "cred", "cookie", "session", "bearer"}, // debug + {"pass", "pwd", "token", "secret", "secure", "key", "auth", "cred", "bearer"}, // cron + } + set := map[string]bool{} + for _, s := range Fragments() { + set[s] = true + } + for _, list := range historical { + for _, frag := range list { + if !set[frag] { + t.Errorf("Fragments() dropped historical fragment %q (regression toward fragmentation)", frag) + } + } + } +} + +// TestSupersetKeysNowRedact proves the unified list gains coverage (pwd/secure) +// without over-promoting "user" at the key layer. +func TestSupersetKeysNowRedact(t *testing.T) { + for _, k := range []string{"pwd", "MYSQL_PWD", "secure", "secure_hash"} { + if !IsSensitiveKey(k) { + t.Errorf("superset miss: %q not sensitive", k) + } + } + if RedactMap(map[string]any{"pwd": "x"})["pwd"] != Placeholder { + t.Error("pwd not redacted in map") + } + if IsSensitiveKey("username") { + t.Error("username must not redact at key layer (user is cron-flag-local only)") + } +} diff --git a/internal/report/analysis.go b/internal/report/analysis.go index d8b728c9..e9b97280 100644 --- a/internal/report/analysis.go +++ b/internal/report/analysis.go @@ -206,7 +206,7 @@ func WriteWebAnalysis(w io.Writer, r WebAnalysisReport) error { fmt.Fprintf(b, " - READY : %d (%d files, %s)\n", ready, files, HumanBytes(bytes)) fmt.Fprintf(b, " - EMPTY : %d (skipped, destination left untouched)\n", empty) fmt.Fprintf(b, " - ABSENT : %d (no docroot on disk)\n", absent) - fmt.Fprintf(b, " - UNREADABLE : %d (docroot exists but permission denied — fix permissions; NOT migrated)\n", unreadable) + fmt.Fprintf(b, " - UNREADABLE : %d (docroot exists but permission denied, fix permissions; NOT migrated)\n", unreadable) if r.SourceOnly { fmt.Fprintf(b, " - NO-DEST : %d (destination not configured)\n", noDest) } else { @@ -221,9 +221,9 @@ func WriteWebAnalysis(w io.Writer, r WebAnalysisReport) error { func dfltDest(s string, sourceOnly bool) string { if s == "" { if sourceOnly { - return "(not configured — source-only analysis)" + return "(not configured, source-only analysis)" } - return "(none — destination domain missing)" + return "(none, destination domain missing)" } return s } @@ -333,7 +333,7 @@ func WriteDBAnalysis(w io.Writer, r DBAnalysisReport) error { fmt.Fprintf(b, "DATABASE: %s [%s]\n", d.SrcDB, dbStatusLabel(d.Status)) fmt.Fprintf(b, " - source : %s (user %s, %s)\n", d.SrcDB, d.SrcUser, HumanBytes(d.DiskUsage)) if r.SourceOnly { - b.WriteString(" - destination: (not configured — source-only analysis)\n") + b.WriteString(" - destination: (not configured, source-only analysis)\n") } else { fmt.Fprintf(b, " - destination: %s (user %s)\n", d.DestDB, d.DestUser) } @@ -350,7 +350,7 @@ func WriteDBAnalysis(w io.Writer, r DBAnalysisReport) error { } fmt.Fprintf(b, " - password : %s\n", provenance) if len(d.Configs) == 0 { - b.WriteString(" - wp-config : (none — orphan database)\n") + b.WriteString(" - wp-config : (none, orphan database)\n") } else { for _, c := range d.Configs { fmt.Fprintf(b, " - wp-config : %s\n", c) diff --git a/internal/report/analysis_test.go b/internal/report/analysis_test.go index ce0316ce..f72b5605 100644 --- a/internal/report/analysis_test.go +++ b/internal/report/analysis_test.go @@ -151,7 +151,7 @@ func TestWriteWebAnalysisSourceOnly(t *testing.T) { out := buf.Bytes() for _, want := range []string{ "# Dest : not configured (source-only analysis)", - "- dest docroot: (not configured — source-only analysis)", + "- dest docroot: (not configured, source-only analysis)", "- READY : 1 (3 files, 42 B)", } { if !bytes.Contains(out, []byte(want)) { @@ -229,7 +229,7 @@ func TestWriteDBAnalysisSourceOnly(t *testing.T) { for _, want := range []string{ "# Dest : not configured (source-only analysis)", "# Prefix : srcacct_ -> (not configured)", - "- destination: (not configured — source-only analysis)", + "- destination: (not configured, source-only analysis)", } { if !bytes.Contains(out, []byte(want)) { t.Errorf("source-only DB analysis missing %q\n%s", want, out) diff --git a/internal/report/report.go b/internal/report/report.go index 88197a59..6bceceb5 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -89,23 +89,23 @@ func (r *Reporter) FileOnlyf(format string, args ...any) { func DomainHeaderLine() string { return "=== Domains ===" } func DomainCreatedLine(domain, kind string) string { - return fmt.Sprintf(" [domain ok] %-28s — %s created", domain, kind) + return fmt.Sprintf(" [domain ok] %-28s: %s created", domain, kind) } func DomainPresentLine(domain, kind string) string { - return fmt.Sprintf(" [domain ok] %-28s — %s already present after refresh", domain, kind) + return fmt.Sprintf(" [domain ok] %-28s: %s already present after refresh", domain, kind) } func DomainFailLine(domain, reason string) string { - return fmt.Sprintf(" [domain FAIL] %-28s — %s", domain, reason) + return fmt.Sprintf(" [domain FAIL] %-28s: %s", domain, reason) } func DomainBlockedLine(domain, reason string) string { - return fmt.Sprintf(" [domain BLOCK] %-27s — %s", domain, reason) + return fmt.Sprintf(" [domain BLOCK] %-27s: %s", domain, reason) } func DomainWarnLine(domain, reason string) string { - return fmt.Sprintf(" [domain WARN] %-28s — %s", domain, reason) + return fmt.Sprintf(" [domain WARN] %-28s: %s", domain, reason) } func DomainSummaryLine(created, present, failed, blocked, warned int) string { @@ -115,28 +115,28 @@ func DomainSummaryLine(created, present, failed, blocked, warned int) string { // UnchangedLine: a box skipped because it was already consistent. func UnchangedLine(email string) string { - return fmt.Sprintf(" [unchanged] %s — already consistent (msg+UIDVALIDITY match), rsync skipped", email) + return fmt.Sprintf(" [unchanged] %s: already consistent (msg+UIDVALIDITY match), rsync skipped", email) } // OKLine: a box migrated; acctState is "created" or "updated". func OKLine(email, acctState string) string { - return fmt.Sprintf(" [ok] %s — account %s, messages synced", email, acctState) + return fmt.Sprintf(" [ok] %s: account %s, messages synced", email, acctState) } // SkipLine: a box skipped for a reason (domain missing / no hash). func SkipLine(email, reason string) string { - return fmt.Sprintf(" [skip] %s — %s", email, reason) + return fmt.Sprintf(" [skip] %s: %s", email, reason) } // FailLine: a box that failed at some step. func FailLine(email, reason string) string { - return fmt.Sprintf(" [FAIL] %s — %s", email, reason) + return fmt.Sprintf(" [FAIL] %s: %s", email, reason) } // UnverifiedLine: a selected box whose mailbox-apply prerequisites were missing, // so the account/password state was not applied and the run must not be clean. func UnverifiedLine(email, reason string) string { - return fmt.Sprintf(" [UNVERIFIED] %s — %s", email, reason) + return fmt.Sprintf(" [UNVERIFIED] %s: %s", email, reason) } // VerifyOKLine: integrity check passed for a box. @@ -152,7 +152,7 @@ func VerifyOKLine(email, msg, uidvalidity string) string { // outside the selected source scope). It is neither OK nor a divergence — an explicit, // visible skip whose root cause is accounted separately. func VerifySkipLine(email, reason string) string { - return fmt.Sprintf(" [verify SKIP] %-32s — %s", email, reason) + return fmt.Sprintf(" [verify SKIP] %-32s: %s", email, reason) } // VerifyDiffLine: integrity check found a divergence. label is the classified @@ -162,7 +162,7 @@ func VerifyDiffLine(email, label, srcMsg, srcUV, destMsg, destUV, note string) s line := fmt.Sprintf(" [verify %-10s] %-32s SRC(msg=%s uv=%s) DEST(msg=%s uv=%s)", label, email, dflt(srcMsg), dflt(srcUV), dflt(destMsg), dflt(destUV)) if note != "" { - line += " — " + note + line += ": " + note } return line } @@ -181,18 +181,18 @@ func WebHeaderLine() string { return "=== Web files ===" } // WebOKLine: a docroot copied. Shows entries sent / total and the bytes moved. func WebOKLine(domain string, filesSent, filesTotal int, bytes int64) string { - return fmt.Sprintf(" [web ok] %-28s — %d/%d files copied (%s)", + return fmt.Sprintf(" [web ok] %-28s: %d/%d files copied (%s)", domain, filesSent, filesTotal, HumanBytes(bytes)) } // WebSkipLine: a docroot skipped (no dest domain / empty / absent). func WebSkipLine(domain, reason string) string { - return fmt.Sprintf(" [web skip] %-28s — %s", domain, reason) + return fmt.Sprintf(" [web skip] %-28s: %s", domain, reason) } // WebFailLine: a docroot whose copy failed. func WebFailLine(domain, reason string) string { - return fmt.Sprintf(" [web FAIL] %-28s — %s", domain, reason) + return fmt.Sprintf(" [web FAIL] %-28s: %s", domain, reason) } // WebVerifyLine: the post-copy integrity check (source vs destination file @@ -238,7 +238,7 @@ func tablesPhrase(tables int, tablesKnown bool) string { // bytes streamed through the dump bridge. tablesKnown is false when the post-import // count failed, so the line says "table count unavailable" rather than "0 tables". func DBOKLine(destDB string, tables int, bytes int64, tablesKnown bool) string { - return fmt.Sprintf(" [db ok] %-26s — %s (%s streamed)", + return fmt.Sprintf(" [db ok] %-26s: %s (%s streamed)", destDB, tablesPhrase(tables, tablesKnown), HumanBytes(bytes)) } @@ -247,23 +247,23 @@ func DBOKLine(destDB string, tables int, bytes int64, tablesKnown bool) string { // database. It is NOT a clean success — the cutover is incomplete and must be // finished manually; the run ends non-zero. func DBPartialLine(destDB string, tables, configsNotRewritten int, bytes int64, tablesKnown bool) string { - return fmt.Sprintf(" [db PARTIAL] %-26s — %s (%s streamed); %d site config(s) NOT rewritten — site still points at the OLD database", + return fmt.Sprintf(" [db PARTIAL] %-26s: %s (%s streamed); %d site config(s) NOT rewritten, site still points at the OLD database", destDB, tablesPhrase(tables, tablesKnown), HumanBytes(bytes), configsNotRewritten) } // DBSkipLine: a database skipped (e.g. data not extractable, only schema). func DBSkipLine(srcDB, reason string) string { - return fmt.Sprintf(" [db skip] %-26s — %s", srcDB, reason) + return fmt.Sprintf(" [db skip] %-26s: %s", srcDB, reason) } // DBFailLine: a database whose copy failed. func DBFailLine(srcDB, reason string) string { - return fmt.Sprintf(" [db FAIL] %-26s — %s", srcDB, reason) + return fmt.Sprintf(" [db FAIL] %-26s: %s", srcDB, reason) } // DBConfigLine: one wp-config.php rewritten on the destination for a database. func DBConfigLine(destDB, configPath string) string { - return fmt.Sprintf(" [db config] %-26s — rewrote %s", destDB, configPath) + return fmt.Sprintf(" [db config] %-26s: rewrote %s", destDB, configPath) } // DBConfigUnverifiedLine: a config the rewrite wrote and re-read, but whose DB cutover @@ -272,7 +272,7 @@ func DBConfigLine(destDB, configPath string) string { // default tier — the data migrated and the value/host checks passed — and a hard failure // under --deep-verify, never a clean [db config] green. func DBConfigUnverifiedLine(destDB, configPath, why string) string { - return fmt.Sprintf(" [db config UNVERIFIED] %-22s — rewrote %s but the cutover is not independently verified: %s", destDB, configPath, why) + return fmt.Sprintf(" [db config UNVERIFIED] %-22s: rewrote %s but the cutover is not independently verified: %s", destDB, configPath, why) } // DBVerifyStatus is the persisted verdict of the post-copy DB integrity check. It diff --git a/internal/report/report_test.go b/internal/report/report_test.go index 8b5d434d..dfdd8feb 100644 --- a/internal/report/report_test.go +++ b/internal/report/report_test.go @@ -45,23 +45,23 @@ func TestReportLines(t *testing.T) { cases := []struct{ got, want string }{ {DomainHeaderLine(), "=== Domains ==="}, {DomainCreatedLine("site.example", "addon"), - " [domain ok] site.example — addon created"}, + " [domain ok] site.example : addon created"}, {DomainPresentLine("site.example", "addon"), - " [domain ok] site.example — addon already present after refresh"}, + " [domain ok] site.example : addon already present after refresh"}, {DomainFailLine("site.example", "create reported success but domain absent after refresh"), - " [domain FAIL] site.example — create reported success but domain absent after refresh"}, + " [domain FAIL] site.example : create reported success but domain absent after refresh"}, {DomainBlockedLine("blocked.example", "addon label collision"), - " [domain BLOCK] blocked.example — addon label collision"}, + " [domain BLOCK] blocked.example : addon label collision"}, {DomainWarnLine("type.example", "destination domain type mismatch"), - " [domain WARN] type.example — destination domain type mismatch"}, + " [domain WARN] type.example : destination domain type mismatch"}, {DomainSummaryLine(1, 2, 3, 4, 5), "Domain creation summary: 1 created, 2 already present, 3 failed, 4 blocked, 5 warning(s)."}, {UnchangedLine("info@addon1.example"), - " [unchanged] info@addon1.example — already consistent (msg+UIDVALIDITY match), rsync skipped"}, + " [unchanged] info@addon1.example: already consistent (msg+UIDVALIDITY match), rsync skipped"}, {OKLine("github@domain4.example", "updated"), - " [ok] github@domain4.example — account updated, messages synced"}, + " [ok] github@domain4.example: account updated, messages synced"}, {UnverifiedLine("info@example.com", "no password hash found on source; account/password not applied"), - " [UNVERIFIED] info@example.com — no password hash found on source; account/password not applied"}, + " [UNVERIFIED] info@example.com: no password hash found on source; account/password not applied"}, {VerifyOKLine("info@domain3.example", "6863", "V1438857057"), " [verify OK] info@domain3.example msg=6863 uidvalidity=V1438857057"}, {VerifyOKLine("info@main.example", "14668", "V1438219516"), @@ -76,7 +76,7 @@ func TestReportLines(t *testing.T) { func TestVerifyDiffLine(t *testing.T) { got := VerifyDiffLine("x@y.it", "INCOMPLETE", "10", "V1", "9", "", "dest is missing 1 message(s)") - want := " [verify INCOMPLETE] x@y.it SRC(msg=10 uv=V1) DEST(msg=9 uv=?) — dest is missing 1 message(s)" + want := " [verify INCOMPLETE] x@y.it SRC(msg=10 uv=V1) DEST(msg=9 uv=?): dest is missing 1 message(s)" if got != want { t.Errorf("\n got: %q\nwant: %q", got, want) } @@ -140,15 +140,15 @@ func TestDBLines(t *testing.T) { cases := []struct{ got, want string }{ {DBHeaderLine(), "=== Databases ==="}, {DBOKLine("destacct_wp694", 88, 19458244, true), - " [db ok] destacct_wp694 — 88 tables (18.6 MB streamed)"}, + " [db ok] destacct_wp694 : 88 tables (18.6 MB streamed)"}, {DBOKLine("destacct_wp694", 0, 19458244, false), - " [db ok] destacct_wp694 — table count unavailable (18.6 MB streamed)"}, - {DBSkipLine("srcacct_wp590", "data not extractable — schema only"), - " [db skip] srcacct_wp590 — data not extractable — schema only"}, + " [db ok] destacct_wp694 : table count unavailable (18.6 MB streamed)"}, + {DBSkipLine("srcacct_wp590", "data not extractable, schema only"), + " [db skip] srcacct_wp590 : data not extractable, schema only"}, {DBFailLine("srcacct_wp551", "dump bridge: connection reset"), - " [db FAIL] srcacct_wp551 — dump bridge: connection reset"}, + " [db FAIL] srcacct_wp551 : dump bridge: connection reset"}, {DBConfigLine("destacct_wp395", "/home/destacct/public_html/site2.example/wp-config.php"), - " [db config] destacct_wp395 — rewrote /home/destacct/public_html/site2.example/wp-config.php"}, + " [db config] destacct_wp395 : rewrote /home/destacct/public_html/site2.example/wp-config.php"}, {DBVerifyLine("destacct_wp694", DBVerifyOK, 88, 88, "", ""), " [db verify OK] destacct_wp694 tables=88"}, {DBVerifyLine("destacct_wp694", DBVerifyDiff, 88, 87, "", ""), @@ -173,11 +173,11 @@ func TestWebLines(t *testing.T) { cases := []struct{ got, want string }{ {WebHeaderLine(), "=== Web files ==="}, {WebOKLine("addon1.example", 812, 812, 833*1024*1024), - " [web ok] addon1.example — 812/812 files copied (833.0 MB)"}, - {WebSkipLine("sub1.example", "source docroot empty — destination left untouched"), - " [web skip] sub1.example — source docroot empty — destination left untouched"}, + " [web ok] addon1.example : 812/812 files copied (833.0 MB)"}, + {WebSkipLine("sub1.example", "source docroot empty, destination left untouched"), + " [web skip] sub1.example : source docroot empty, destination left untouched"}, {WebFailLine("domain3.example", "empty destination docroot: GUARD"), - " [web FAIL] domain3.example — empty destination docroot: GUARD"}, + " [web FAIL] domain3.example : empty destination docroot: GUARD"}, {WebVerifyLine("domain4.example", true, 3, 3, 16384, 16384), " [web verify OK] domain4.example files=3 bytes=16.0 KB"}, {WebVerifyLine("domain4.example", false, 3, 2, 16384, 8192), diff --git a/internal/sshtest/sshtest.go b/internal/sshtest/sshtest.go index c2bf767f..334e469e 100644 --- a/internal/sshtest/sshtest.go +++ b/internal/sshtest/sshtest.go @@ -67,7 +67,7 @@ func serveExec(conn net.Conn, cfg *ssh.ServerConfig, home string) { if err != nil { return } - defer sconn.Close() + defer func() { _ = sconn.Close() }() go ssh.DiscardRequests(reqs) for newCh := range chans { if newCh.ChannelType() != "session" { diff --git a/internal/sshx/client.go b/internal/sshx/client.go index 46a0520a..6f39b3f3 100644 --- a/internal/sshx/client.go +++ b/internal/sshx/client.go @@ -173,12 +173,12 @@ func (c *Client) newSession(ctx context.Context, what string) (*ssh.Session, err // the connection is saturated otherwise looks like a phantom transfer bug. // This lets the operator correlate it without needing --log-level debug. if open > 0 { - return nil, fmt.Errorf("%w (%d session(s) already open on this connection — possible MaxSessions limit)", err, open) + return nil, fmt.Errorf("%w (%d session(s) already open on this connection, possible MaxSessions limit)", err, open) } return nil, err } n := c.openSes.Add(1) - logx.Debug("%s: session opened [%s] — now %d open", c.name, what, n) + logx.Debug("%s: session opened [%s], now %d open", c.name, what, n) return sess, nil } @@ -186,7 +186,7 @@ func (c *Client) newSession(ctx context.Context, what string) (*ssh.Session, err func (c *Client) closeSession(sess *ssh.Session, what string) { _ = sess.Close() n := c.openSes.Add(-1) - logx.Debug("%s: session closed [%s] — now %d open", c.name, what, n) + logx.Debug("%s: session closed [%s], now %d open", c.name, what, n) } // Dial opens a password-authenticated SSH connection to addr (host:port). @@ -344,7 +344,7 @@ func (c *Client) keepaliveLoop(cli *ssh.Client, interval time.Duration, stop cha c.markDead(gen) return } - logx.Debug("keepalive on %s: no reply within %s (miss %d/%d) — tolerating (likely a busy transfer saturating the link)", + logx.Debug("keepalive on %s: no reply within %s (miss %d/%d), tolerating (likely a busy transfer saturating the link)", c.name, interval, misses, keepaliveMaxMisses) } } diff --git a/internal/sshx/client_dial_test.go b/internal/sshx/client_dial_test.go index 6ca957c6..1efc7d82 100644 --- a/internal/sshx/client_dial_test.go +++ b/internal/sshx/client_dial_test.go @@ -99,7 +99,7 @@ func TestDialSilentBannerReclaimsConn(t *testing.T) { select { case <-closed: // the watchdog closed the owned conn case <-time.After(3 * time.Second): - t.Error("owned connection was not reclaimed after the handshake timeout — leak") + t.Error("owned connection was not reclaimed after the handshake timeout, leak") } } @@ -141,7 +141,7 @@ func TestDialAbortClosesOwnedConn(t *testing.T) { deadline := time.Now().Add(3 * time.Second) for !tc.closed.Load() { if time.Now().After(deadline) { - t.Fatal("the owned connection was not closed — leaked") + t.Fatal("the owned connection was not closed, leaked") } time.Sleep(5 * time.Millisecond) } diff --git a/internal/sshx/hostkey.go b/internal/sshx/hostkey.go index 5a31f28f..2bad58f0 100644 --- a/internal/sshx/hostkey.go +++ b/internal/sshx/hostkey.go @@ -69,8 +69,8 @@ func AcceptNewHostKey(path string) (ssh.HostKeyCallback, error) { // recorded key: a real mismatch -> reject. Warn (not Debug): a // changed host key is security-relevant and the operator must see it // even at the default info level, not only the error returned below. - logx.Warn("host key MISMATCH for %s — refusing to connect (the recorded key has CHANGED; possible MITM or a rebuilt server)", hostname) - return fmt.Errorf("%w: host key mismatch for %s (known_hosts has a different key) — refusing to connect", ErrHostKeyChanged, hostname) + logx.Warn("host key MISMATCH for %s, refusing to connect (the recorded key has CHANGED; possible MITM or a rebuilt server)", hostname) + return fmt.Errorf("%w: host key mismatch for %s (known_hosts has a different key), refusing to connect", ErrHostKeyChanged, hostname) } // Host unknown (no recorded keys): accept-new. Append and reload. logx.Debug("host key: accepting new key for %s", hostname) diff --git a/internal/sshx/pool.go b/internal/sshx/pool.go index 1763cd95..86b4037e 100644 --- a/internal/sshx/pool.go +++ b/internal/sshx/pool.go @@ -87,6 +87,36 @@ func DialBoth(ctx context.Context, cfg config.Config, knownHostsPath string) (*P return p, nil } +// DialDest opens a connection to the DESTINATION host only, using the +// accept-new host-key policy backed by knownHostsPath (default +// ~/.ssh/known_hosts when empty). It never dials the source, so a DNS +// apply or verify can run against the destination even when the source is +// already decommissioned. Returns an error if the destination is not +// configured. Password-based, mirroring DialBoth's destination branch. +func DialDest(ctx context.Context, cfg config.Config, knownHostsPath string) (*Client, error) { + // Honor a pre-cancelled context before any TOFU filesystem side effect. + if err := ctx.Err(); err != nil { + return nil, err + } + if !cfg.DestConfigured() { + return nil, fmt.Errorf("destination host is not configured") + } + if knownHostsPath == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("locate home dir: %w", err) + } + knownHostsPath = filepath.Join(home, ".ssh", "known_hosts") + } + cb, err := AcceptNewHostKey(knownHostsPath) + if err != nil { + return nil, err + } + return Dial(ctx, "dest", + net.JoinHostPort(cfg.Dest.IP, strconv.Itoa(cfg.Dest.Port)), + cfg.Dest.SSHUser, cfg.Dest.SSHPass, cfg.Dest.Timeout, keepaliveInterval, cb) +} + // Close shuts down both connections. Safe to call once. func (p *Pool) Close() error { var firstErr error diff --git a/internal/sshx/retry.go b/internal/sshx/retry.go index e690d20e..d4ab775f 100644 --- a/internal/sshx/retry.go +++ b/internal/sshx/retry.go @@ -143,7 +143,7 @@ func RetryBatch(ctx context.Context, label string, timeout time.Duration, addByt // the per-item label and the idle window), and wrap lastErr so the final // returned error names the stall instead of the unblocked-copy noise. if context.Cause(bctx) == ErrStalled && ctx.Err() == nil { - logx.Debug("%s: attempt %d STALLED — no progress for %s; aborting and retrying (unblocked with: %v)", + logx.Debug("%s: attempt %d STALLED, no progress for %s; aborting and retrying (unblocked with: %v)", label, attempt, timeout, err) lastErr = fmt.Errorf("%w (no progress for %s; last I/O error: %v)", ErrStalled, timeout, err) } else { diff --git a/internal/sshx/retry_test.go b/internal/sshx/retry_test.go index 3d33bfa4..9801d7a1 100644 --- a/internal/sshx/retry_test.go +++ b/internal/sshx/retry_test.go @@ -111,7 +111,7 @@ func TestRetryBatchHonorsCancelledContext(t *testing.T) { t.Error("a cancelled context must surface as an error") } if calls != 1 { - t.Errorf("calls = %d, want 1 — a cancelled ctx must stop further attempts", calls) + t.Errorf("calls = %d, want 1, a cancelled ctx must stop further attempts", calls) } } diff --git a/internal/sshx/stream.go b/internal/sshx/stream.go index 7fbce555..d63aac73 100644 --- a/internal/sshx/stream.go +++ b/internal/sshx/stream.go @@ -104,6 +104,15 @@ func StreamNul(ctx context.Context, c *Client, cmd string, stdin io.Reader, onRe }) } +// StreamNul is the method form of the package-level StreamNul, so callers that +// hold a *Client (e.g. the account-inventory batch prefetch) can drive a +// NUL-framed batched read without importing the free function separately. It +// forwards verbatim, so the ctx-abort / close-error / 1 MiB record-cap +// semantics are identical. +func (c *Client) StreamNul(ctx context.Context, cmd string, stdin io.Reader, onRecord func(string) error) error { + return StreamNul(ctx, c, cmd, stdin, onRecord) +} + // scanNull is a bufio.SplitFunc that splits on NUL bytes. A trailing // unterminated chunk at EOF is returned as a final token; the empty tail after a // terminating NUL yields no token (so `a\0b\0` produces exactly "a","b"). @@ -277,7 +286,7 @@ func (r *StreamReader) Close() error { return fmt.Errorf("%s: %q failed: %w (stderr: %s)", r.name, r.cmd, err, r.stderr.String()) } if feedErr != nil { - logx.Warn("%s: reading the file list for %q failed (%v) — the transfer is INCOMPLETE: tar received a truncated list", r.name, r.cmd, feedErr) + logx.Warn("%s: reading the file list for %q failed (%v), the transfer is INCOMPLETE: tar received a truncated list", r.name, r.cmd, feedErr) return fmt.Errorf("%s: file-list feed failed (transfer incomplete): %w", r.name, feedErr) } return nil diff --git a/internal/sshx/stream_bridge_test.go b/internal/sshx/stream_bridge_test.go index 21fea9cd..04018c12 100644 --- a/internal/sshx/stream_bridge_test.go +++ b/internal/sshx/stream_bridge_test.go @@ -131,7 +131,7 @@ func withTimeout(t *testing.T, d time.Duration, fn func() error) error { case err := <-done: return err case <-time.After(d): - t.Fatalf("operation did not return within %v — deadlock", d) + t.Fatalf("operation did not return within %v, deadlock", d) return nil } } diff --git a/internal/testdata/db_analysis.golden b/internal/testdata/db_analysis.golden index 844529d7..c55aa68c 100644 --- a/internal/testdata/db_analysis.golden +++ b/internal/testdata/db_analysis.golden @@ -16,7 +16,7 @@ DATABASE: srcacct_wp590 [ORPHAN] - source : srcacct_wp590 (user srcacct_wp590, 928.0 KB) - destination: destacct_wp590 (user destacct_wp590) - password : to be generated - - wp-config : (none — orphan database) + - wp-config : (none, orphan database) DATABASE: srcacct_wp694 [LINKED] - source : srcacct_wp694 (user srcacct_u1, 22.3 MB) diff --git a/internal/testdata/dns_fetchzone_records.json b/internal/testdata/dns_fetchzone_records.json new file mode 100644 index 00000000..5717d3ee --- /dev/null +++ b/internal/testdata/dns_fetchzone_records.json @@ -0,0 +1,111 @@ +{ + "cpanelresult": { + "apiversion": 2, + "func": "fetchzone_records", + "module": "ZoneEdit", + "preevent": { + "result": 1 + }, + "postevent": { + "result": 1 + }, + "data": [ + { + "record": null, + "Line": 1, + "line": 1, + "type": ":RAW", + "raw": "; cPanel first:11.62.0.19 Cpanel::ZoneFile::VERSION:1.3 hostname:server.example.test" + }, + { + "line": 2, + "Line": 2, + "record": null, + "ttl": "14400", + "type": "$TTL" + }, + { + "line": 3, + "Line": 3, + "Lines": 1, + "type": "SOA", + "class": "IN", + "name": "example.com.", + "ttl": 86400, + "record": null, + "mname": "ns1.example.com", + "rname": "admin.example.com", + "serial": "2024010101", + "refresh": "3600", + "retry": "1800", + "expire": "1209600", + "minimum": "86400" + }, + { + "line": 4, + "Line": 4, + "type": "NS", + "class": "IN", + "name": "example.com.", + "ttl": 86400, + "record": null, + "nsdname": "ns1.example.com" + }, + { + "line": 5, + "Line": 5, + "type": "A", + "class": "IN", + "name": "example.com.", + "ttl": 14400, + "record": "192.168.1.1", + "address": "192.168.1.1" + }, + { + "line": 6, + "Line": 6, + "type": "AAAA", + "class": "IN", + "name": "example.com.", + "ttl": 14400, + "record": "2001:db8::1", + "address": "2001:db8::1" + }, + { + "line": 7, + "Line": 7, + "type": "MX", + "class": "IN", + "name": "example.com.", + "ttl": 14400, + "record": null, + "exchange": "mail.example.com", + "preference": "10" + }, + { + "line": 8, + "Line": 8, + "type": "CNAME", + "class": "IN", + "name": "www.example.com.", + "ttl": 14400, + "record": "example.com", + "cname": "example.com" + }, + { + "line": 9, + "Line": 9, + "type": "TXT", + "class": "IN", + "name": "example.com.", + "ttl": 14400, + "record": null, + "txtdata": "v=spf1 include:_spf.google.com ~all", + "unencoded": 1 + } + ], + "event": { + "result": 1 + } + } +} diff --git a/internal/testdata/dns_parse_zone.json b/internal/testdata/dns_parse_zone.json new file mode 100644 index 00000000..e54b2e60 --- /dev/null +++ b/internal/testdata/dns_parse_zone.json @@ -0,0 +1,94 @@ +{ + "result": { + "data": [ + { + "dname_b64": "ZXhhbXBsZS5jb20u", + "record_type": "A", + "data_b64": [ + "MTkyLjE2OC4xLjE=" + ], + "ttl": 14400, + "line_index": 1, + "type": "record" + }, + { + "dname_b64": "ZXhhbXBsZS5jb20u", + "record_type": "AAAA", + "data_b64": [ + "MjAwMTpkYjg6OjE=" + ], + "ttl": 14400, + "line_index": 2, + "type": "record" + }, + { + "dname_b64": "bWFpbC5leGFtcGxlLmNvbS4=", + "record_type": "CNAME", + "data_b64": [ + "ZXhhbXBsZS5jb20u" + ], + "ttl": 14400, + "line_index": 3, + "type": "record" + }, + { + "dname_b64": "ZXhhbXBsZS5jb20u", + "record_type": "MX", + "data_b64": [ + "MTA=", + "bWFpbC5leGFtcGxlLmNvbS4=" + ], + "ttl": 14400, + "line_index": 4, + "type": "record" + }, + { + "dname_b64": "ZXhhbXBsZS5jb20u", + "record_type": "TXT", + "data_b64": [ + "dj1zcGYxIGluY2x1ZGU6X3NwZi5nb29nbGUuY29tIH5hbGw=" + ], + "ttl": 14400, + "line_index": 5, + "type": "record" + }, + { + "dname_b64": "ZXhhbXBsZS5jb20u", + "record_type": "NS", + "data_b64": [ + "bnMxLmV4YW1wbGUuY29tLg==" + ], + "ttl": 86400, + "line_index": 6, + "type": "record" + }, + { + "dname_b64": "ZGtpbS5fZG9tYWlua2V5LmV4YW1wbGUuY29tLg==", + "record_type": "TXT", + "data_b64": [ + "dj1ES0lNMTsgaz1yc2E7IHA9TUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEE=", + "UUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUE=" + ], + "ttl": 14400, + "line_index": 7, + "type": "record" + }, + { + "text_b64": "OyBab25lIGZpbGUgZm9yIGV4YW1wbGUuY29t", + "line_index": 0, + "type": "comment" + }, + { + "text_b64": "JFRUTCAxNDQwMA==", + "line_index": 8, + "type": "control" + } + ], + "errors": null, + "messages": null, + "metadata": { + "transformed": 1 + }, + "status": 1 + } +} diff --git a/internal/testdata/dns_verify_report.md.golden b/internal/testdata/dns_verify_report.md.golden new file mode 100644 index 00000000..e38bf572 --- /dev/null +++ b/internal/testdata/dns_verify_report.md.golden @@ -0,0 +1,48 @@ +# DNS Verify Report + +- **Plan**: dns_import_plan.json (sha256 ccc) +- **Generated**: t +- **Verdict**: NOT CLEAN + +**Summary**: 1 applied, 0 unchanged, 1 pending, 1 drift, 1 manual review, 0 not checked, 1 untracked; 0 unavailable zone(s), 1 manual zone(s) + +The verdict gates on pending, drift, unavailable zones and manual zones; manual ops and untracked rrsets are reported for review only. + +## Zones excluded from the plan (each one keeps the verdict NOT CLEAN, re-run the pipeline) + +| Zone | Reason | +|------|--------| +| missing.example | zone missing on destination - create it via WHM/park first, then re-run | + +## Zone example.com (fetched via uapi) + +### DRIFT (1) + +| Type | Name | Reason | Expected | Observed | +|------|------|--------|----------|----------| +| A | same.example.com. | live rrset no longer matches the plan-time destination state | 198.51.100.1 | 5.5.5.5 | + +### PENDING (1) + +| Type | Name | Reason | Expected | Observed | +|------|------|--------|----------|----------| +| CNAME | www.example.com. | rrset still missing on the destination (plan-time state) | example.com. | | + +### MANUAL REVIEW (1) + +| Type | Name | Reason | Expected | Observed | +|------|------|--------|----------|----------| +| NS | example.com. | NS/delegation is registrar/WHM territory - never written | | ns1.new.example. | + +### APPLIED (1) + +| Type | Name | Reason | Expected | Observed | +|------|------|--------|----------|----------| +| A | new.example.com. | | 198.51.100.1 | 198.51.100.1 | + +### Untracked live rrsets (1, postdate the plan, review only) + +| Type | Name | Values | +|------|------|--------| +| TXT | postplan.example.com. | added later | + diff --git a/internal/testdata/email_autoresponders.json b/internal/testdata/email_autoresponders.json new file mode 100644 index 00000000..9530f025 --- /dev/null +++ b/internal/testdata/email_autoresponders.json @@ -0,0 +1 @@ +{"result":{"data":[{"email":"info","from":"Info Desk","subject":"Out of office","body":"Thank you for your email. We will respond shortly.","domain":"main.example","interval":24,"is_html":0,"start":1719792000,"stop":0}],"errors":null,"messages":null,"status":1}} diff --git a/internal/testdata/email_default_address_realserver.json b/internal/testdata/email_default_address_realserver.json new file mode 100644 index 00000000..8c66c029 --- /dev/null +++ b/internal/testdata/email_default_address_realserver.json @@ -0,0 +1 @@ +{"result":{"errors":null,"data":[{"domain":"example.com","defaultaddress":"\":fail: No Such User Here\""},{"domain":"noleggio.example.com","defaultaddress":"\":fail: No Such User Here\""},{"defaultaddress":"\":fail: No Such User Here\"","domain":"shop.example.com"},{"domain":"shop2.example.com","defaultaddress":"\":fail: No Such User Here\""},{"domain":"example.com","defaultaddress":"\":fail: No Such User Here\""},{"defaultaddress":"\":fail: No Such User Here\"","domain":"cz.example.com"},{"domain":"hu.example.com","defaultaddress":"\":fail: No Such User Here\""}],"warnings":null,"messages":null,"status":1,"metadata":{"transformed":1}}} diff --git a/internal/testdata/email_forwarders.json b/internal/testdata/email_forwarders.json new file mode 100644 index 00000000..eab8ded1 --- /dev/null +++ b/internal/testdata/email_forwarders.json @@ -0,0 +1 @@ +{"result":{"data":[{"dest":"info@main.example","forward":"admin@gmail.com","html_dest":"info@main.example","html_forward":"admin@gmail.com","uri_dest":"info%40main.example","uri_forward":"admin%40gmail.com"},{"dest":"sales@main.example","forward":"sales@company.com, backup@company.com","html_dest":"sales@main.example","html_forward":"sales@company.com, backup@company.com","uri_dest":"sales%40main.example","uri_forward":"sales%40company.com%2C+backup%40company.com"}],"errors":null,"messages":null,"status":1}} diff --git a/internal/testdata/email_get_filter_spam-to-junk.json b/internal/testdata/email_get_filter_spam-to-junk.json new file mode 100644 index 00000000..b177deed --- /dev/null +++ b/internal/testdata/email_get_filter_spam-to-junk.json @@ -0,0 +1 @@ +{"result":{"errors":null,"data":{"filtername":"spam-to-junk","rules":[{"part":"$header_subject:","match":"contains","opt":null,"val":"[SPAM]","number":1}],"actions":[{"action":"save","dest":"$home/mail/.Junk","number":1}]},"warnings":null,"messages":null,"status":1,"metadata":{}}} \ No newline at end of file diff --git a/internal/testdata/email_list_filters.json b/internal/testdata/email_list_filters.json new file mode 100644 index 00000000..241d641d --- /dev/null +++ b/internal/testdata/email_list_filters.json @@ -0,0 +1 @@ +{"result":{"errors":null,"data":[{"filtername":"spam-to-junk","enabled":1,"rules":[{"part":"$header_subject:","match":"contains","val":"[SPAM]","opt":"or"}],"actions":[{"action":"save","dest":"$home/mail/.Junk"}]},{"filtername":"legacy-disabled","enabled":"0","rules":[{"part":"$header_from:","match":"is","val":"anonimo@example.test","opt":"or"},{"part":"$header_subject:","match":"contains","val":"promo","opt":"and"}],"actions":[{"action":"deliver","dest":"anonimo@example.test"},{"action":"fail","dest":""}]}],"warnings":null,"messages":null,"status":1,"metadata":{"transformed":1}}} diff --git a/internal/testdata/email_list_mxs_realserver.json b/internal/testdata/email_list_mxs_realserver.json new file mode 100644 index 00000000..72df15f8 --- /dev/null +++ b/internal/testdata/email_list_mxs_realserver.json @@ -0,0 +1 @@ +{"result":{"errors":null,"data":[{"remote":0,"secondary":0,"statusmsg":"Fetched MX List","domain":"example.com","mxcheck":"local","status":1,"alwaysaccept":1,"local":1,"entries":[{"row":"odd","mx":"example.com","domain":"example.com","entrycount":1,"priority":"0"}],"mx":"example.com","detected":"local"},{"mxcheck":"remote","status":1,"statusmsg":"Fetched MX List","secondary":0,"remote":1,"domain":"example.com","mx":"example.com","detected":"remote","alwaysaccept":0,"local":0,"entries":[{"domain":"example.com","entrycount":1,"priority":"0","mx":"example.com","row":"odd"}]}],"warnings":null,"messages":null,"status":1,"metadata":{"transformed":1}}} diff --git a/internal/testdata/email_list_pops.json b/internal/testdata/email_list_pops.json new file mode 100644 index 00000000..b03249b8 --- /dev/null +++ b/internal/testdata/email_list_pops.json @@ -0,0 +1 @@ +{"result":{"data":[{"email":"info@main.example","domain":"main.example","login":"info@main.example","diskused":"0.00","_diskused":"2048","diskquota":"unlimited","diskusedpercent":0},{"email":"admin@main.example","domain":"main.example","login":"admin@main.example","diskused":"0.05","_diskused":"51200","diskquota":"5120.00","diskusedpercent":0},{"email":"contact@addon.example","domain":"addon.example","login":"contact@addon.example","diskused":"0.00","_diskused":"0","diskquota":"unlimited","diskusedpercent":0}],"errors":null,"messages":null,"status":1}} diff --git a/internal/testdata/ftp_list.json b/internal/testdata/ftp_list.json new file mode 100644 index 00000000..66856782 --- /dev/null +++ b/internal/testdata/ftp_list.json @@ -0,0 +1 @@ +{"result":{"data":[{"login":"main@main.example","accttype":"main","dir":"/home/srcuser/public_html","reldir":"public_html","diskquota":"unlimited","diskused":150,"diskusedpercent":0,"deleteable":0,"_diskquota":0,"_diskused":150},{"login":"uploads@main.example","accttype":"sub","dir":"/home/srcuser/public_html/uploads","reldir":"public_html/uploads","diskquota":"500","diskused":25,"diskusedpercent":5,"deleteable":1,"_diskquota":500,"_diskused":25}],"errors":null,"messages":null,"status":1}} diff --git a/internal/testdata/migration_checklist.md.golden b/internal/testdata/migration_checklist.md.golden new file mode 100644 index 00000000..60477827 --- /dev/null +++ b/internal/testdata/migration_checklist.md.golden @@ -0,0 +1,108 @@ +# Migration Checklist - srcacct + +**Overall: BLOCKED** + +- **Generated**: 2026-07-02T10:00:00Z +- **Chain verified**: no - input hashes missing or mismatched (see warnings) + +## Summary + +- OK: 7 +- Expected differences: 2 +- Manual actions: 3 +- Accepted by operator: 0 +- Review required: 0 +- Blocked: 1 +- Not migrated by tool: 0 +- Not inventoried: 0 +- Not accessible without root: 2 + +## Sections + +- [REVIEW] **domains** - 1 expected difference(s), migrated by tool (run_level evidence) +- [OK] **web_files** - ok - migrated by tool (run_level evidence) +- [OK] **mailboxes** - ok - migrated by tool (run_level evidence) +- [OK] **databases** - ok - migrated by tool (run_level evidence) +- [OK] **forwarders** - ok +- [NA] **autoresponders** - not applicable +- [NA] **ftp** - not applicable +- [REVIEW] **ssl** - 1 manual action(s), 1 expected difference(s) +- [MANUAL] **php** - 1 manual action(s) +- [OK] **dns** - ok +- [BLOCKED] **cron** - 1 blocker(s), 1 manual action(s) +- [OK] **email_routing** - ok +- [OK] **default_address** - ok +- [NA] **email_filters** - not applicable +- [NA] **redirects** - not applicable +- [WARN] **quota_package** - not accessible without root +- [WARN] **server_level_config** - not accessible without root + +## Coverage + +| Area | State | Note | +|------|-------|------| +| domains | covered | | +| web_files | covered | | +| mailboxes | covered | | +| databases | covered | | +| forwarders | covered | | +| autoresponders | covered | | +| ftp | covered | | +| ssl | covered | | +| php | covered | | +| dns | covered | | +| cron | covered | | +| email_routing | covered | | +| default_address | covered | | +| email_filters | covered | | +| redirects | covered | | +| quota_package | root_only | package assignment, quotas and bandwidth limits are WHM territory | +| server_level_config | root_only | PHP handlers, web server, firewall and system crons are not visible with account-level access | +| api_tokens | not_collected | API token NAMES are listable user-level; secrets are never retrievable - historical-dossier material | +| boxtrapper | not_collected | BoxTrapper enable state and configuration | +| contact_info | not_collected | account contact addresses and notification preferences | +| directory_privacy | not_collected | password-protected directories (~/.htpasswds) - the protection passwords are at stake on transfer | +| domain_aliases | not_collected | parked/alias domains as a dedicated field - today folded into the domains listing | +| git_repositories | not_collected | cPanel-registered git repositories (working trees travel with the home transfer, the registrations do not) | +| hotlink_protection | not_collected | hotlink protection configuration | +| leech_protection | not_collected | leech protection configuration | +| mailbox_quota_limits | not_collected | per-mailbox quota LIMITS - usage is collected, the configured limit is not | +| mailing_lists | not_collected | Mailman mailing lists (member rosters are root-only and cannot be migrated user-level) | +| mime_handlers | not_collected | custom MIME types and Apache handlers | +| passenger_apps | not_collected | registered Passenger/Node/Python applications - files travel with the transfer, the registrations do not | +| spamassassin | not_collected | SpamAssassin enable state and user_prefs (~/.spamassassin is outside the docroot copy) | +| ssh_keys | not_collected | SSH key METADATA (names/fingerprints only - private keys are never collected) | +| team_users | not_collected | cPanel Team user accounts (their passwords cannot be migrated) | +| webdisk_accounts | not_collected | WebDisk accounts (passwords would need regeneration on the destination) | + +## Manual actions (3) + +| ID | Key | Blocking | Section | Type | Action | +|----|-----|----------|---------|------|--------| +| MA-001 | AK-bc3693aa249f | no | ssl | ACCEPT_EXPECTED_DIFFERENCE | Acknowledge the reissued certificate for main.example - The destination certificate differs from the source but is currently valid; ackno... | +| MA-002 | AK-308556e552a4 | no | php | CHECK_PHP_COMPATIBILITY | Check PHP compatibility for main.example - Test the site against the destination PHP configuration before cutover. | +| MA-003 | AK-c45ba4c9e844 | **yes** | cron | ADAPT_CRON_PATH | Recreate active cron job - Recreate this cron job on the destination adapting the /home/ paths to the new account. | + +## Before shutting down the old server + +1. [MA-003] Recreate active cron job - Recreate this cron job on the destination adapting the /home/ paths to the new account. + +## Post-cutover checks + +1. Load every site over HTTPS and confirm the homepage renders from the destination. +2. Send and receive a test message for at least one mailbox per domain. +3. Run AutoSSL on the destination and confirm every domain serves a valid certificate. +4. Verify public DNS resolves every domain to the destination server once TTLs expire. +5. Confirm recreated cron jobs actually ran (check their output/log once). + +## Inputs + +| Input | Present | File | SHA256 | +|-------|---------|------|--------| +| source inventory | yes | inventory_source.json | aaa | +| destination inventory | yes | inventory_destination.json | bbb | +| diff | yes | inventory_diff.json | ccc | +| policy | yes | policy_report.json | ddd | +| dns plan | no | | | +| migration report | yes | report.json | eee | +| acceptances | no | | | diff --git a/internal/testdata/mime_redirects_realserver.json b/internal/testdata/mime_redirects_realserver.json new file mode 100644 index 00000000..054c9b24 --- /dev/null +++ b/internal/testdata/mime_redirects_realserver.json @@ -0,0 +1 @@ +{"result":{"errors":null,"data":[{"matchwww":1,"matchwww_text":"checked","kind":"rewrite","type":"permanent","targeturl":"https://sub.example.com/","destination":"https://sub.example.com/","opts":"L","domain":"sub-uk.example.com","wildcard_text":"checked","sourceurl":"/","wildcard":1,"displaysourceurl":"/","displaydomain":"sub-uk.example.com","docroot":"/home/example/sub-uk.example.com","source":"/","urldomain":"sub-uk.example.com","statuscode":"301"},{"wildcard_text":"","sourceurl":"/([0-9])(-[_a-zA-Z0-9-]*)?(-[0-9]+)?/.+.jpg","wildcard":0,"displaysourceurl":"/([0-9])(-[_a-zA-Z0-9-]*)?(-[0-9]+)?/.+.jpg","displaydomain":"noleggio.example.com","domain":"noleggio.example.com","urldomain":"noleggio.example.com","statuscode":null,"docroot":"/home/example/noleggio.example.com","source":"/([0-9])(-[_a-zA-Z0-9-]*)?(-[0-9]+)?/.+.jpg","kind":"rewrite","type":"temporary","matchwww_text":"","matchwww":0,"opts":"L","destination":"%{ENV:REWRITEBASE}img/p/$1/$1$2$3.jpg","targeturl":"%{ENV:REWRITEBASE}img/p/$1/$1$2$3.jpg"},{"statuscode":null,"urldomain":"noleggio.example.com","source":"/c/([a-zA-Z_-]+)(-[0-9]+)?/.+.jpg","docroot":"/home/example/noleggio.example.com","displaydomain":"noleggio.example.com","displaysourceurl":"/c/([a-zA-Z_-]+)(-[0-9]+)?/.+.jpg","wildcard":0,"wildcard_text":"","sourceurl":"/c/([a-zA-Z_-]+)(-[0-9]+)?/.+.jpg","domain":"noleggio.example.com","destination":"%{ENV:REWRITEBASE}img/c/$1$2.jpg","opts":"L","targeturl":"%{ENV:REWRITEBASE}img/c/$1$2.jpg","type":"temporary","kind":"rewrite","matchwww_text":"","matchwww":0}],"warnings":null,"messages":null,"status":1,"metadata":{"transformed":1}}} diff --git a/internal/testdata/php_vhost_versions.json b/internal/testdata/php_vhost_versions.json new file mode 100644 index 00000000..69a2b475 --- /dev/null +++ b/internal/testdata/php_vhost_versions.json @@ -0,0 +1 @@ +{"result":{"data":[{"vhost":"main.example","version":"ea-php81","account":"srcuser","documentroot":"/home/srcuser/public_html","homedir":"/home/srcuser","main_domain":1,"is_suspended":0},{"vhost":"addon.example","version":"ea-php74","account":"srcuser","documentroot":"/home/srcuser/addon.example","homedir":"/home/srcuser","main_domain":0,"is_suspended":0}],"errors":null,"messages":null,"status":1}} diff --git a/internal/testdata/ssl_list_certs.json b/internal/testdata/ssl_list_certs.json new file mode 100644 index 00000000..6ab44bb4 --- /dev/null +++ b/internal/testdata/ssl_list_certs.json @@ -0,0 +1 @@ +{"result":{"data":[{"id":"cert_abc123","friendly_name":"main.example","domains":"main.example,www.main.example","issuer.commonName":"R3","issuer.organizationName":"Let's Encrypt","not_before":1717200000,"not_after":1724976000,"is_self_signed":0,"validation_type":"dv","modulus_length":2048},{"id":"cert_def456","friendly_name":"addon.example","domains":"addon.example","issuer.commonName":"R3","issuer.organizationName":"Let's Encrypt","not_before":1717200000,"not_after":1724976000,"is_self_signed":0,"validation_type":"dv","modulus_length":2048}],"errors":null,"messages":null,"status":1}} diff --git a/internal/testdata/web_analysis.golden b/internal/testdata/web_analysis.golden index 8fbd8147..eaf5449f 100644 --- a/internal/testdata/web_analysis.golden +++ b/internal/testdata/web_analysis.golden @@ -21,7 +21,7 @@ DOMAIN: main.example [READY] DOMAIN: newsite.example [NO-DEST] - src docroot : /home/srcacct/newsite.example - - dest docroot: (none — destination domain missing) + - dest docroot: (none, destination domain missing) DOMAIN: sub1.example [EMPTY] - src docroot : /home/srcacct/sub1.example @@ -32,5 +32,5 @@ TOTAL DOCROOTS : 5 - READY : 2 (25642 files, 768.0 MB) - EMPTY : 1 (skipped, destination left untouched) - ABSENT : 0 (no docroot on disk) - - UNREADABLE : 1 (docroot exists but permission denied — fix permissions; NOT migrated) + - UNREADABLE : 1 (docroot exists but permission denied, fix permissions; NOT migrated) - NO-DEST : 1 (destination domain missing) diff --git a/internal/webfiles/digest_test.go b/internal/webfiles/digest_test.go index 9a07743c..18d0cc72 100644 --- a/internal/webfiles/digest_test.go +++ b/internal/webfiles/digest_test.go @@ -129,7 +129,7 @@ func TestGetManifestProgressPhases(t *testing.T) { t.Error("deep GetManifest: expected a listing-phase tick (hashing=false)") } if !sawHash { - t.Error("deep GetManifest: expected a hashing-phase tick (hashing=true) — the slow pass must drive the row") + t.Error("deep GetManifest: expected a hashing-phase tick (hashing=true), the slow pass must drive the row") } files := 0 for rel, e := range m { diff --git a/internal/webfiles/gather.go b/internal/webfiles/gather.go index 5762c53e..d473c154 100644 --- a/internal/webfiles/gather.go +++ b/internal/webfiles/gather.go @@ -55,7 +55,7 @@ func Gather(ctx context.Context, src Runner, items []WebPlanItem) ([]WebPlanItem it.Notes = append(it.Notes, "source docroot absent: "+it.SrcDocroot) case count == 0: it.Skip = true - it.Notes = append(it.Notes, "source docroot empty — destination left untouched") + it.Notes = append(it.Notes, "source docroot empty, destination left untouched") default: it.SrcBytes = bytes it.SrcFileCount = count @@ -95,9 +95,9 @@ func DocrootDigest(ctx context.Context, c Runner, docroot string) (bytes int64, // an over-cap docroot and neither self-resolves. switch { case status == sizeUnreadable: - logx.Warn("webfiles digest %s: docroot present but a subtree is unreadable — the over-cap mirror cannot be name-verified", docroot) + logx.Warn("webfiles digest %s: docroot present but a subtree is unreadable, the over-cap mirror cannot be name-verified", docroot) case status == sizePresent && dg == "": - logx.Warn("webfiles digest %s: sha256sum / 'sort -z' unavailable on the host — the over-cap docroot cannot be name-verified", docroot) + logx.Warn("webfiles digest %s: sha256sum / 'sort -z' unavailable on the host, the over-cap docroot cannot be name-verified", docroot) } return b, n, dg, status == sizePresent, status == sizeUnreadable, nil } @@ -120,9 +120,9 @@ func DocrootContentDigest(ctx context.Context, c Runner, docroot string) (bytes logx.Debug("webfiles content-digest %s: bytes=%d files=%d digest=%q status=%d", docroot, b, n, dg, status) switch { case status == sizeUnreadable: - logx.Warn("webfiles content-digest %s: docroot present but a subtree is unreadable — the content mirror cannot be verified", docroot) + logx.Warn("webfiles content-digest %s: docroot present but a subtree is unreadable, the content mirror cannot be verified", docroot) case status == sizePresent && dg == "": - logx.Warn("webfiles content-digest %s: sha256sum / 'sort -z' unavailable on the host — the docroot content cannot be byte-verified", docroot) + logx.Warn("webfiles content-digest %s: sha256sum / 'sort -z' unavailable on the host, the docroot content cannot be byte-verified", docroot) } return b, n, dg, status == sizePresent, status == sizeUnreadable, nil } diff --git a/internal/webfiles/guard.go b/internal/webfiles/guard.go index 6cc61fc5..c5a3e121 100644 --- a/internal/webfiles/guard.go +++ b/internal/webfiles/guard.go @@ -36,7 +36,7 @@ func ValidateDestTargets(ctx context.Context, r Runner, items []WebPlanItem) ([] if it.Skip || it.DestDocroot == "" { continue } - canon, err := CanonicalDestDocroot(ctx, r, it.DestDocroot) + canon, err := CanonicalDestDocroot(ctx, r, it.DestDocroot, it.AllowDestPublicHTMLRoot) if err != nil { issues = append(issues, DestTargetIssue{ Domain: it.Domain, @@ -73,8 +73,10 @@ func ValidateDestTargets(ctx context.Context, r Runner, items []WebPlanItem) ([] // CanonicalDestDocroot returns the destination host's canonical path for docroot, // after applying the same containment guard used by empty/backup/extract. -func CanonicalDestDocroot(ctx context.Context, r Runner, docroot string) (string, error) { - out, err := r.RunScript(ctx, canonicalDestDocrootScript(), map[string]string{"DEST_DOCROOT": docroot}) +// allowRoot is the per-docroot opt-in (WebPlanItem.AllowDestPublicHTMLRoot) that +// lets the guard accept ~/public_html itself as the target. +func CanonicalDestDocroot(ctx context.Context, r Runner, docroot string, allowRoot bool) (string, error) { + out, err := r.RunScript(ctx, canonicalDestDocrootScript(), destDocrootEnv(docroot, allowRoot)) if err != nil { return "", fmt.Errorf("canonicalize destination docroot %q: %w", docroot, err) } @@ -93,3 +95,15 @@ func canonicalDestDocrootScript() string { printf '%s\n' "$d" ` } + +// destDocrootEnv is the single source for the guarded destination scripts' env: +// the docroot plus, only when the plan item opted in, the guard's +// ALLOW_PUBLIC_HTML_ROOT flag. Never set the flag unconditionally (its absence +// is what keeps the public_html root refusal active for every other docroot). +func destDocrootEnv(docroot string, allowRoot bool) map[string]string { + env := map[string]string{"DEST_DOCROOT": docroot} + if allowRoot { + env["ALLOW_PUBLIC_HTML_ROOT"] = "1" + } + return env +} diff --git a/internal/webfiles/guard_test.go b/internal/webfiles/guard_test.go index 5f2bbc55..795bbff9 100644 --- a/internal/webfiles/guard_test.go +++ b/internal/webfiles/guard_test.go @@ -41,7 +41,7 @@ func TestCanonicalDestDocrootGuard(t *testing.T) { r := localRunner{home: home} t.Run("allows missing leaf under public_html", func(t *testing.T) { raw := filepath.Join(ph, "newsite.example") - got, err := CanonicalDestDocroot(context.Background(), r, raw) + got, err := CanonicalDestDocroot(context.Background(), r, raw, false) if err != nil { t.Fatalf("CanonicalDestDocroot: %v", err) } @@ -62,7 +62,7 @@ func TestCanonicalDestDocrootGuard(t *testing.T) { } for name, raw := range rejects { t.Run(name, func(t *testing.T) { - if got, err := CanonicalDestDocroot(context.Background(), r, raw); err == nil { + if got, err := CanonicalDestDocroot(context.Background(), r, raw, false); err == nil { t.Fatalf("CanonicalDestDocroot(%q) = %q, want error", raw, got) } }) diff --git a/internal/webfiles/mainroot_test.go b/internal/webfiles/mainroot_test.go new file mode 100644 index 00000000..cd9f607a --- /dev/null +++ b/internal/webfiles/mainroot_test.go @@ -0,0 +1,240 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package webfiles + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tis24dev/cPanel_self-migration/internal/sshx" +) + +// The ALLOW_PUBLIC_HTML_ROOT=1 opt-in covers the 1:1 account migration layout +// (source main domain -> destination account rebuilt with the SAME main +// domain): there the destination docroot legitimately IS ~/public_html. The +// flag relaxes ONLY the exact-equality refusal; every other guard (escape, +// '..', relative paths) must hold with the flag set, and the flag must change +// nothing when absent. + +func TestGuardPublicHTMLRootHonorsAllowFlag(t *testing.T) { + requireBash(t) + home := t.TempDir() + ph := filepath.Join(home, "public_html") + if err := os.MkdirAll(ph, 0o755); err != nil { + t.Fatal(err) + } + outside := filepath.Join(home, "outside") + if err := os.MkdirAll(outside, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(ph, "link-out")); err != nil { + t.Fatal(err) + } + + t.Run("root refused without flag", func(t *testing.T) { + if out, err := execScript(home, canonicalDestDocrootScript(), map[string]string{ + "DEST_DOCROOT": ph, + }); err == nil { + t.Fatalf("guard must refuse public_html root without the flag, got %q", out) + } + }) + t.Run("root refused with flag not equal to 1", func(t *testing.T) { + if out, err := execScript(home, canonicalDestDocrootScript(), map[string]string{ + "DEST_DOCROOT": ph, "ALLOW_PUBLIC_HTML_ROOT": "yes", + }); err == nil { + t.Fatalf("guard must refuse public_html root unless the flag is exactly 1, got %q", out) + } + }) + t.Run("root allowed with flag", func(t *testing.T) { + out, err := execScript(home, canonicalDestDocrootScript(), map[string]string{ + "DEST_DOCROOT": ph, "ALLOW_PUBLIC_HTML_ROOT": "1", + }) + if err != nil { + t.Fatalf("guard must allow public_html root with ALLOW_PUBLIC_HTML_ROOT=1: %v", err) + } + got := strings.TrimSpace(out) + want, rerr := filepath.EvalSymlinks(ph) + if rerr != nil { + want = ph + } + if got != want { + t.Fatalf("canonical path = %q, want %q", got, want) + } + }) + // The flag must NOT weaken any other refusal. + stillRejected := map[string]string{ + "outside home": outside, + "dotdot escape": ph + "/site/../..", + "dotdot inside": ph + "/site/../other", + "symlink escape": filepath.Join(ph, "link-out", "site"), + "relative path": "public_html", + "home itself": home, + } + for name, raw := range stillRejected { + t.Run("flag does not unlock "+name, func(t *testing.T) { + if out, err := execScript(home, canonicalDestDocrootScript(), map[string]string{ + "DEST_DOCROOT": raw, "ALLOW_PUBLIC_HTML_ROOT": "1", + }); err == nil { + t.Fatalf("guard must still refuse %q with the flag set, got %q", raw, out) + } + }) + } +} + +func TestEmptyDestScriptPublicHTMLRootWithFlag(t *testing.T) { + requireBash(t) + home := t.TempDir() + ph := filepath.Join(home, "public_html") + populate := func() { + mustWrite(t, filepath.Join(ph, "index.html"), "default page") + mustWrite(t, filepath.Join(ph, "wp-content", "x.php"), " do not transfer this domain + + // AllowDestPublicHTMLRoot marks the 1:1 account-migration layout (source + // MAIN domain -> destination account rebuilt with the SAME main domain), + // where the destination docroot legitimately IS ~/public_html. It is the + // per-item opt-in that lets the destination containment guard accept the + // public_html root as a target (ALLOW_PUBLIC_HTML_ROOT=1); every other + // guard check still applies. Set by BuildPlan, never by hand. + AllowDestPublicHTMLRoot bool } // BuildPlan joins the source and destination docroots by domain name. It @@ -87,7 +95,7 @@ func BuildPlan(src, dest []DocrootEntry) []WebPlanItem { if dup := collisions[key]; len(dup) > 0 { item.Skip = true item.Notes = append(item.Notes, canonicalDocrootCollisionNote(s.Domain, dup)) - logx.Debug("webfiles plan: %s has a destination canonical-domain collision — will skip", s.Domain) + logx.Debug("webfiles plan: %s has a destination canonical-domain collision, will skip", s.Domain) out = append(out, item) continue } @@ -95,10 +103,21 @@ func BuildPlan(src, dest []DocrootEntry) []WebPlanItem { if !ok || d.DocumentRoot == "" { item.Skip = true item.Notes = append(item.Notes, - "no destination domain '"+s.Domain+"' — create it first") - logx.Debug("webfiles plan: %s has no dest docroot match — will skip", s.Domain) + "no destination domain '"+s.Domain+"', create it first") + logx.Debug("webfiles plan: %s has no dest docroot match, will skip", s.Domain) } else { item.DestDocroot = d.DocumentRoot + // Same-FQDN main to main (the join above already matched by canonical + // name): the destination main docroot is the intended target of this + // migration, so the containment guard may accept ~/public_html itself. + // + // KEEP IN LOCKSTEP with sameNameMainToMain in + // internal/migrate/domain_type_issues.go: that predicate is the actual + // authorization checkpoint (applyWebFiles refuses BlockWeb items before + // they ever reach CopyDocroot); this flag only relaxes the filesystem + // guard for items that already cleared it. Loosening either predicate + // without the other silently changes what the pair authorizes. + item.AllowDestPublicHTMLRoot = s.Type == "main_domain" && d.Type == "main_domain" } out = append(out, item) } diff --git a/internal/webfiles/stream.go b/internal/webfiles/stream.go index 7d5390fd..578a0312 100644 --- a/internal/webfiles/stream.go +++ b/internal/webfiles/stream.go @@ -201,17 +201,17 @@ func ParseGatherStream(r io.Reader, total int, hooks GatherHooks) (map[string]Ga return results, fmt.Errorf("read gather stream: %w (%d of %d docroot(s) completed%s)", err, len(results), total, strayNote(strayIn+strayOut)) } if cur != "" { - return results, fmt.Errorf("gather stream truncated: docroot %q left open (no END/ABSENT/UNREADABLE) — %d of %d docroot(s) completed%s", cur, len(results), total, strayNote(strayIn+strayOut)) + return results, fmt.Errorf("gather stream truncated: docroot %q left open (no END/ABSENT/UNREADABLE), %d of %d docroot(s) completed%s", cur, len(results), total, strayNote(strayIn+strayOut)) } if !seenAllDone { - return results, fmt.Errorf("gather stream truncated: no ALLDONE terminator — %d of %d docroot(s) completed%s", len(results), total, strayNote(strayIn+strayOut)) + return results, fmt.Errorf("gather stream truncated: no ALLDONE terminator, %d of %d docroot(s) completed%s", len(results), total, strayNote(strayIn+strayOut)) } // The stream completed, but a non-numeric line INSIDE a docroot frame means that // docroot's reported size/count is understated. That only feeds the analyze / // dry-run display (not the copy), but it must not pass silently — surface it so an // operator is not misled by an under-counted total. Out-of-frame noise is ignored. if strayIn > 0 { - logx.Warn("gather stream completed but %d unparseable size line(s) inside a docroot were ignored — analyze totals may undercount", strayIn) + logx.Warn("gather stream completed but %d unparseable size line(s) inside a docroot were ignored, analyze totals may undercount", strayIn) } return results, nil } diff --git a/internal/webfiles/transfer.go b/internal/webfiles/transfer.go index 62ebbaab..2bf8ba64 100644 --- a/internal/webfiles/transfer.go +++ b/internal/webfiles/transfer.go @@ -69,7 +69,7 @@ func (t Transfer) CopyDocroot(ctx context.Context, item WebPlanItem, prog Progre if item.SrcDocroot == "" || item.DestDocroot == "" { return SyncResult{}, fmt.Errorf("%s: missing docroot (src=%q dest=%q)", item.Domain, item.SrcDocroot, item.DestDocroot) } - if _, err := CanonicalDestDocroot(ctx, t.Dest, item.DestDocroot); err != nil { + if _, err := CanonicalDestDocroot(ctx, t.Dest, item.DestDocroot, item.AllowDestPublicHTMLRoot); err != nil { return SyncResult{}, fmt.Errorf("%s: destination docroot preflight: %w", item.Domain, err) } @@ -81,8 +81,8 @@ func (t Transfer) CopyDocroot(ctx context.Context, item WebPlanItem, prog Progre if len(files) == 0 { // No transfer entries at all. Preserve the destination aside and leave a // fresh empty docroot instead of wiping live content based on an empty source. - logx.Debug("CopyDocroot %s: source has no transfer entries — backing up the destination instead of wiping it", item.Domain) - return t.backupDest(ctx, item.DestDocroot) + logx.Debug("CopyDocroot %s: source has no transfer entries, backing up the destination instead of wiping it", item.Domain) + return t.backupDest(ctx, item.DestDocroot, item.AllowDestPublicHTMLRoot) } dirOnly := true var bytes int64 @@ -101,8 +101,8 @@ func (t Transfer) CopyDocroot(ctx context.Context, item WebPlanItem, prog Progre // Directory-only sources are content: listScript already reported the empty // directories, and verifyWebFiles will expect them. Preserve old destination // content aside, then stream the directory entries into the fresh live root. - logx.Debug("CopyDocroot %s: source has only empty directories — backing up destination before streaming directories", item.Domain) - backup, err := t.backupDest(ctx, item.DestDocroot) + logx.Debug("CopyDocroot %s: source has only empty directories, backing up destination before streaming directories", item.Domain) + backup, err := t.backupDest(ctx, item.DestDocroot, item.AllowDestPublicHTMLRoot) if err != nil { return res, err } @@ -110,7 +110,7 @@ func (t Transfer) CopyDocroot(ctx context.Context, item WebPlanItem, prog Progre } else { // Empty the destination ONCE, before any extract. Doing it per-batch would // wipe earlier batches. - if err := t.emptyDest(ctx, item.DestDocroot); err != nil { + if err := t.emptyDest(ctx, item.DestDocroot, item.AllowDestPublicHTMLRoot); err != nil { return res, fmt.Errorf("%s: empty destination docroot: %w", item.Domain, err) } } @@ -124,7 +124,7 @@ func (t Transfer) CopyDocroot(ctx context.Context, item WebPlanItem, prog Progre if prog != nil { prog.SetBatch(i+1, len(batches)) } - if err := t.syncBatch(ctx, item.SrcDocroot, item.DestDocroot, batch, prog); err != nil { + if err := t.syncBatch(ctx, item.SrcDocroot, item.DestDocroot, item.AllowDestPublicHTMLRoot, batch, prog); err != nil { return res, fmt.Errorf("%s: batch %d/%d: %w", item.Domain, i+1, len(batches), err) } } @@ -171,7 +171,7 @@ func (t Transfer) listSrcFiles(ctx context.Context, docroot string, onList func( } if srcAbsent { // Fail closed: never mutate the destination based on a source we could not read. - return nil, fmt.Errorf("source docroot %s is absent or unreadable — refusing to touch the destination", docroot) + return nil, fmt.Errorf("source docroot %s is absent or unreadable, refusing to touch the destination", docroot) } if onList != nil { onList(len(files)) // final exact count @@ -187,9 +187,9 @@ func (t Transfer) listSrcFiles(ctx context.Context, docroot string, onList func( } // emptyDest runs the guarded empty-docroot script on the destination. -func (t Transfer) emptyDest(ctx context.Context, docroot string) error { +func (t Transfer) emptyDest(ctx context.Context, docroot string, allowRoot bool) error { logx.Debug("emptyDest %s: guarded clean (preserving %s)", docroot, strings.Join(systemExcludes, ", ")) - _, err := t.Dest.RunScript(ctx, emptyDestScript(), map[string]string{"DEST_DOCROOT": docroot}) + _, err := t.Dest.RunScript(ctx, emptyDestScript(), destDocrootEnv(docroot, allowRoot)) return err } @@ -200,8 +200,8 @@ func (t Transfer) emptyDest(ctx context.Context, docroot string) error { // source without losing the old files. The public_html root itself is rejected by // the destination containment guard. An absent/already-empty docroot is a no-op. // Writes ONLY on the destination. -func (t Transfer) backupDest(ctx context.Context, docroot string) (SyncResult, error) { - out, err := t.Dest.RunScript(ctx, backupDestScript(), map[string]string{"DEST_DOCROOT": docroot}) +func (t Transfer) backupDest(ctx context.Context, docroot string, allowRoot bool) (SyncResult, error) { + out, err := t.Dest.RunScript(ctx, backupDestScript(), destDocrootEnv(docroot, allowRoot)) if err != nil { return SyncResult{}, fmt.Errorf("back up destination docroot %s: %w", docroot, err) } @@ -213,7 +213,7 @@ func (t Transfer) backupDest(ctx context.Context, docroot string) (SyncResult, e // syncBatch streams one batch of entries via the tar bridge, with retries. The // entry list is fed to the source tar via stdin (--files-from=-), so even a long // list never hits an argv limit. -func (t Transfer) syncBatch(ctx context.Context, srcDocroot, destDocroot string, batch []FileEntry, prog ProgressSink) error { +func (t Transfer) syncBatch(ctx context.Context, srcDocroot, destDocroot string, allowRoot bool, batch []FileEntry, prog ProgressSink) error { names := make([]string, len(batch)) for i, f := range batch { names[i] = f.RelPath @@ -230,7 +230,7 @@ func (t Transfer) syncBatch(ctx context.Context, srcDocroot, destDocroot string, } label := fmt.Sprintf("webfiles batch %s (%d entries)", srcDocroot, len(batch)) return sshx.RetryBatch(ctx, label, t.Timeout, addBytes, func(bctx context.Context, onBytes func(int64)) error { - return t.streamOnce(bctx, srcDocroot, destDocroot, fileList, onBytes) + return t.streamOnce(bctx, srcDocroot, destDocroot, allowRoot, fileList, onBytes) }) } @@ -244,8 +244,8 @@ func (t Transfer) syncBatch(ctx context.Context, srcDocroot, destDocroot string, // the bridge is called with a nil env map (it reserves SSH Setenv delivery for // secrets like the DB import's MYSQL_PWD). The destination was already emptied by // emptyDest, so a plain extract (source wins) is correct. -func (t Transfer) streamOnce(ctx context.Context, srcDocroot, destDocroot, fileList string, onBytes func(int64)) error { +func (t Transfer) streamOnce(ctx context.Context, srcDocroot, destDocroot string, allowRoot bool, fileList string, onBytes func(int64)) error { srcCmd := sshx.WithEnv(srcTarCmd, map[string]string{"SRC_DOCROOT": srcDocroot}) - destCmd := sshx.WithEnv(extractCmd, map[string]string{"DEST_DOCROOT": destDocroot}) + destCmd := sshx.WithEnv(extractCmd, destDocrootEnv(destDocroot, allowRoot)) return sshx.BridgeProgress(ctx, t.Src, srcCmd, nil, strings.NewReader(fileList), t.Dest, destCmd, nil, onBytes) } diff --git a/internal/webfiles/webfiles.go b/internal/webfiles/webfiles.go index c11272c4..f97945ab 100644 --- a/internal/webfiles/webfiles.go +++ b/internal/webfiles/webfiles.go @@ -369,6 +369,15 @@ guard_dest_docroot() { d_real="$(guard_under_public_html_or_root "$raw")" || return $? ph_real="$(canon_existing_path "$HOME/public_html")" || { echo "GUARD: cannot resolve public_html root ($HOME/public_html)" >&2; return 10; } if [ "$d_real" = "$ph_real" ]; then + # ALLOW_PUBLIC_HTML_ROOT=1 is the explicit per-docroot opt-in for the 1:1 + # account migration layout (source main domain -> destination account with + # the SAME main domain), where the destination docroot legitimately IS + # ~/public_html. It relaxes ONLY this exact-equality refusal; every other + # containment check above still applies. + if [ "${ALLOW_PUBLIC_HTML_ROOT:-0}" = "1" ]; then + printf '%s\n' "$d_real" + return 0 + fi echo "GUARD: refuse public_html root ($raw -> $d_real)" >&2 return 11 fi @@ -452,6 +461,16 @@ func backupDestScript() string { return fmt.Sprintf(`set -u %s raw_d="$(guard_dest_docroot "$DEST_DOCROOT")" || exit $? +# The backup path renames the docroot aside and recreates it. That can NEVER be +# done to ~/public_html itself, even under ALLOW_PUBLIC_HTML_ROOT=1: once the +# root is renamed, the guard's $HOME/public_html anchor no longer resolves and +# the fresh docroot could not be recreated, leaving the account with no web +# root. Refuse BEFORE any mutation (fail closed). +ph_guard="$(canon_existing_path "$HOME/public_html")" || { echo "GUARD: cannot resolve public_html root ($HOME/public_html)" >&2; exit 10; } +if [ "$raw_d" = "$ph_guard" ]; then + echo "GUARD: refuse to back up the public_html root itself ($raw_d)" >&2 + exit 11 +fi if [ ! -e "$raw_d" ]; then ensure_guarded_dest_docroot_dir "$raw_d" || exit $? echo "NOBAK" diff --git a/internal/workbench/artifacts.go b/internal/workbench/artifacts.go new file mode 100644 index 00000000..bc139aa8 --- /dev/null +++ b/internal/workbench/artifacts.go @@ -0,0 +1,165 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/version" +) + +// AttachArtifact copies the file at srcPath into the session's artifact +// directory, computes its SHA256, and records it in the session. The kind +// must be one of the known ArtifactKinds. The source file must exist and be +// a regular file. The copy is atomic (write-temp + rename) to prevent +// partial artifacts on crash. +func (s *Store) AttachArtifact(sessionID string, kind ArtifactKind, srcPath string, now time.Time) (*Session, error) { + if !ValidArtifactKind(kind) { + return nil, fmt.Errorf("%w: %q", ErrUnknownArtifactKind, string(kind)) + } + if !isCleanID(sessionID) { + return nil, fmt.Errorf("%w: %q", ErrInvalidSessionID, sessionID) + } + + // Open the source file BEFORE acquiring the lock to eliminate TOCTOU on the + // lock: we hold the fd throughout, so a swap after open cannot affect us. + // The operator-supplied path may legitimately be a symlink (plain os.Open + // followed them), so resolve it first, then open the RESOLVED real file + // through an os.Root scoped to its resolved parent (mirrors + // sshx.openContained): after resolution the base is a real component, so the + // root only guards a last-instant symlink swap of that final component. The + // returned file is independent of the root handle, so the root is closed + // immediately after the open. + realPath, err := filepath.EvalSymlinks(srcPath) + if err != nil { + return nil, fmt.Errorf("artifact source: %w", err) + } + srcRoot, err := os.OpenRoot(filepath.Dir(realPath)) + if err != nil { + return nil, fmt.Errorf("artifact source: %w", err) + } + srcFile, err := srcRoot.Open(filepath.Base(realPath)) + _ = srcRoot.Close() + if err != nil { + return nil, fmt.Errorf("artifact source: %w", err) + } + defer func() { _ = srcFile.Close() }() + info, err := srcFile.Stat() + if err != nil { + return nil, fmt.Errorf("artifact source stat: %w", err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("artifact source %q is not a regular file", srcPath) + } + + fl, flErr := s.lockFile() + if flErr != nil { + return nil, flErr + } + defer unlockFile(fl) + s.mu.Lock() + defer s.mu.Unlock() + + sess, err := s.readSession(sessionID) + if err != nil { + return nil, err + } + + // Collision-proof destination: use a random suffix + randBytes := make([]byte, 4) + if _, err := rand.Read(randBytes); err != nil { + return nil, fmt.Errorf("generate artifact suffix: %w", err) + } + ext := filepath.Ext(srcPath) + if ext == "" { + ext = ".json" + } + // SECURITY: derive the artifact dir from the validated store root + the + // isCleanID-checked sessionID, never from sess.ArtifactDir (read verbatim + // from session.json by readSession; a crafted "artifact_dir":"/etc" or + // "../.." would otherwise escape the store root). writeSession is already + // hardened the same way via the validated folderID. + artifactDir := filepath.Join(s.root, sessionID, "artifacts") + if err := os.MkdirAll(artifactDir, 0700); err != nil { + return nil, fmt.Errorf("ensure artifact dir: %w", err) + } + sess.ArtifactDir = artifactDir // self-heal a tampered record on next write + destName := fmt.Sprintf("%s_%s_%s%s", kind, now.UTC().Format("20060102_150405"), hex.EncodeToString(randBytes), ext) + destPath := filepath.Join(artifactDir, destName) + + hash, err := copyFromFD(srcFile, destPath) + if err != nil { + return nil, fmt.Errorf("copy artifact: %w", err) + } + + entry := ArtifactEntry{ + Kind: kind, + Path: destPath, + SHA256: hash, + AttachedAt: now, + } + sess.Artifacts = append(sess.Artifacts, entry) + sess.UpdatedAt = now + sess.Timeline = append(sess.Timeline, TimelineEvent{ + Timestamp: now, + Action: "attach_artifact", + Reason: fmt.Sprintf("kind=%s sha256=%s", kind, hash), + ToolVersion: version.String(), + }) + + if err := s.writeSession(sessionID, sess); err != nil { + _ = os.Remove(destPath) // best-effort cleanup; return the write error + return nil, err + } + return sess, nil +} + +// copyFromFD copies from an already-opened source file descriptor to dst +// atomically (write-temp + fsync + rename) and returns the SHA256 hex digest. +// Using an fd eliminates the TOCTOU window between stat and open. +func copyFromFD(in *os.File, dst string) (string, error) { + dir := filepath.Dir(dst) + tmp, err := os.CreateTemp(dir, "artifact-*.tmp") + if err != nil { + return "", err + } + tmpName := tmp.Name() + + h := sha256.New() + w := io.MultiWriter(tmp, h) + + if _, err := io.Copy(w, in); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return "", err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return "", err + } + if err := tmp.Chmod(0600); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return "", err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return "", err + } + + if err := os.Rename(tmpName, dst); err != nil { + _ = os.Remove(tmpName) + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/workbench/artifacts_test.go b/internal/workbench/artifacts_test.go new file mode 100644 index 00000000..53d0f293 --- /dev/null +++ b/internal/workbench/artifacts_test.go @@ -0,0 +1,245 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAttachArtifactCopiesFile(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + // Create a source artifact file + srcFile := filepath.Join(t.TempDir(), "checklist.json") + content := []byte(`{"status":"ready"}`) + if err := os.WriteFile(srcFile, content, 0644); err != nil { + t.Fatal(err) + } + + updated, err := s.AttachArtifact(sess.ID, ArtifactMigrationChecklist, srcFile, now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + + if len(updated.Artifacts) != 1 { + t.Fatalf("artifacts len = %d, want 1", len(updated.Artifacts)) + } + art := updated.Artifacts[0] + if art.Kind != ArtifactMigrationChecklist { + t.Errorf("kind = %q", art.Kind) + } + + // Verify the file was COPIED into the session dir + copiedData, err := os.ReadFile(art.Path) + if err != nil { + t.Fatalf("read copied artifact: %v", err) + } + if string(copiedData) != string(content) { + t.Error("copied content differs") + } + + // Original removal should not affect the copy + os.Remove(srcFile) + if _, err := os.ReadFile(art.Path); err != nil { + t.Error("copy lost when original removed") + } +} + +// TestAttachArtifactFollowsSymlinkSource pins that an operator-supplied source +// that is a SYMLINK is still attached (plain os.Open followed symlinks). The +// link uses an absolute target in a different directory, the case os.Root +// refuses outright, so AttachArtifact must resolve it before confining the read. +func TestAttachArtifactFollowsSymlinkSource(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + content := []byte(`{"status":"ready"}`) + + targetDir := t.TempDir() + target := filepath.Join(targetDir, "real-plan.json") + if err := os.WriteFile(target, content, 0644); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "plan.json") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + sess, _ := s.Create("test", "src", "dst", now) + updated, err := s.AttachArtifact(sess.ID, ArtifactDNSPlan, link, now.Add(time.Minute)) + if err != nil { + t.Fatalf("attach symlinked source: %v", err) + } + if len(updated.Artifacts) != 1 { + t.Fatalf("artifacts len = %d, want 1", len(updated.Artifacts)) + } + got, err := os.ReadFile(updated.Artifacts[0].Path) + if err != nil { + t.Fatalf("read copied artifact: %v", err) + } + if string(got) != string(content) { + t.Errorf("copied content = %q, want %q", got, content) + } +} + +func TestAttachArtifactComputesSHA256(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + content := []byte(`{"data":"value"}`) + srcFile := filepath.Join(t.TempDir(), "data.json") + os.WriteFile(srcFile, content, 0644) + + updated, _ := s.AttachArtifact(sess.ID, ArtifactPolicyReport, srcFile, now) + art := updated.Artifacts[0] + + h := sha256.Sum256(content) + expected := hex.EncodeToString(h[:]) + if art.SHA256 != expected { + t.Errorf("sha256 = %q, want %q", art.SHA256, expected) + } +} + +func TestAttachArtifactRejectsUnknownKind(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + srcFile := filepath.Join(t.TempDir(), "data.json") + os.WriteFile(srcFile, []byte("x"), 0644) + + _, err := s.AttachArtifact(sess.ID, "host_yaml", srcFile, now) + if err == nil { + t.Fatal("expected error for unknown kind") + } +} + +func TestAttachArtifactRejectsMissingFile(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + _, err := s.AttachArtifact(sess.ID, ArtifactDNSPlan, "/nonexistent/file.json", now) + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestAttachArtifactPermissions(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + srcFile := filepath.Join(t.TempDir(), "data.json") + os.WriteFile(srcFile, []byte("x"), 0644) + + updated, _ := s.AttachArtifact(sess.ID, ArtifactDNSPlan, srcFile, now) + art := updated.Artifacts[0] + + info, err := os.Stat(art.Path) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("artifact perm = %o, want 0600", perm) + } +} + +func TestAttachArtifactPathTraversal(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + // Try to use a traversal path as session ID + _, err := s.AttachArtifact("../../../etc/passwd", ArtifactDNSPlan, "/tmp/x", now) + if err == nil { + t.Fatal("expected error for path traversal in session ID") + } + + // Valid session but source file with traversal in name should still work + // (we copy by content, the destination filename is derived from kind) + srcFile := filepath.Join(t.TempDir(), "normal.json") + os.WriteFile(srcFile, []byte("ok"), 0644) + updated, err := s.AttachArtifact(sess.ID, ArtifactDNSPlan, srcFile, now) + if err != nil { + t.Fatalf("valid attach failed: %v", err) + } + // The copied file must be inside the session's artifact dir + rel, relErr := filepath.Rel(sess.ArtifactDir, updated.Artifacts[0].Path) + if relErr != nil || len(rel) > 1 && rel[:2] == ".." { + t.Errorf("artifact stored outside session dir: %s", updated.Artifacts[0].Path) + } +} + +func TestAttachArtifactMultipleSameKind(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + srcFile := filepath.Join(t.TempDir(), "v1.json") + os.WriteFile(srcFile, []byte("v1"), 0644) + s.AttachArtifact(sess.ID, ArtifactDNSPlan, srcFile, now) + + srcFile2 := filepath.Join(t.TempDir(), "v2.json") + os.WriteFile(srcFile2, []byte("v2"), 0644) + updated, _ := s.AttachArtifact(sess.ID, ArtifactDNSPlan, srcFile2, now.Add(time.Second)) + + // Both versions are kept (append, not replace) + if len(updated.Artifacts) != 2 { + t.Fatalf("artifacts len = %d, want 2", len(updated.Artifacts)) + } +} + +func TestAttachArtifactIgnoresCraftedArtifactDir(t *testing.T) { + root := t.TempDir() + s, err := NewStore(root) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, err := s.Create("acme-web", "src", "dst", now) + if err != nil { + t.Fatal(err) + } + + // Tamper: rewrite session.json so artifact_dir points OUTSIDE the store. + escape := t.TempDir() + sess.ArtifactDir = escape + data, _ := json.MarshalIndent(sess, "", " ") + if err := os.WriteFile(filepath.Join(root, sess.ID, "session.json"), data, 0600); err != nil { + t.Fatal(err) + } + + src := filepath.Join(t.TempDir(), "plan.json") + if err := os.WriteFile(src, []byte(`{"plan":"ok"}`), 0644); err != nil { + t.Fatal(err) + } + + got, err := s.AttachArtifact(sess.ID, ArtifactDNSPlan, src, now) + if err != nil { + t.Fatal(err) + } + + wantDir := filepath.Join(root, sess.ID, "artifacts") + stored := got.Artifacts[len(got.Artifacts)-1].Path + rel, relErr := filepath.Rel(wantDir, stored) + if relErr != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + t.Fatalf("artifact escaped store root: stored=%q wantDir=%q", stored, wantDir) + } + if entries, _ := os.ReadDir(escape); len(entries) != 0 { + t.Fatalf("artifact leaked into crafted dir %q: %d entries", escape, len(entries)) + } + if got.ArtifactDir != wantDir { + t.Errorf("ArtifactDir not self-healed: got %q want %q", got.ArtifactDir, wantDir) + } +} diff --git a/internal/workbench/flock.go b/internal/workbench/flock.go new file mode 100644 index 00000000..9637324a --- /dev/null +++ b/internal/workbench/flock.go @@ -0,0 +1,38 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +//go:build !windows + +package workbench + +import ( + "fmt" + "os" + "syscall" +) + +// lockFile acquires an exclusive advisory lock on the store's lock file. +// Returns the lock file (caller must call unlockFile when done). +// This serializes cross-process access to the session store. +func (s *Store) lockFile() (*os.File, error) { + root, err := os.OpenRoot(s.root) + if err != nil { + return nil, fmt.Errorf("open store root: %w", err) + } + defer func() { _ = root.Close() }() + f, err := root.OpenFile(".lock", os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, fmt.Errorf("open lock file: %w", err) + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + _ = f.Close() + return nil, fmt.Errorf("acquire lock: %w", err) + } + return f, nil +} + +// unlockFile releases the advisory lock and closes the file. +func unlockFile(f *os.File) { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) // best-effort advisory unlock on release + _ = f.Close() +} diff --git a/internal/workbench/flock_windows.go b/internal/workbench/flock_windows.go new file mode 100644 index 00000000..31b81bd0 --- /dev/null +++ b/internal/workbench/flock_windows.go @@ -0,0 +1,34 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +//go:build windows + +package workbench + +import ( + "fmt" + "os" +) + +// lockFile on Windows is a best-effort no-op: it creates/opens the lock file +// but takes no OS advisory lock (stdlib exposes no portable flock on Windows, +// and adding golang.org/x/sys would change go.mod). The in-process mutex in +// Store still serializes same-process access; this tool is Linux first, where +// flock.go provides real cross-process locking. +func (s *Store) lockFile() (*os.File, error) { + root, err := os.OpenRoot(s.root) + if err != nil { + return nil, fmt.Errorf("open store root: %w", err) + } + defer func() { _ = root.Close() }() + f, err := root.OpenFile(".lock", os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, fmt.Errorf("open lock file: %w", err) + } + return f, nil +} + +// unlockFile releases the lock file (no OS unlock needed for the no-op lock). +func unlockFile(f *os.File) { + _ = f.Close() +} diff --git a/internal/workbench/safety_test.go b/internal/workbench/safety_test.go new file mode 100644 index 00000000..c00fef6a --- /dev/null +++ b/internal/workbench/safety_test.go @@ -0,0 +1,139 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench_test + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestWorkbenchNoForbiddenImports ensures the workbench package never imports +// sshx or cpanel, it must remain offline and credential-free. +func TestWorkbenchNoForbiddenImports(t *testing.T) { + forbidden := []string{ + "github.com/tis24dev/cPanel_self-migration/internal/sshx", + "github.com/tis24dev/cPanel_self-migration/internal/cpanel", + "github.com/tis24dev/cPanel_self-migration/internal/config", + } + + dir := "." + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + + fset := token.NewFileSet() + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { + continue + } + if strings.HasSuffix(e.Name(), "_test.go") { + continue + } + path := filepath.Join(dir, e.Name()) + f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + for _, imp := range f.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + for _, fb := range forbidden { + if importPath == fb { + t.Errorf("%s imports forbidden package %q, workbench must be offline", e.Name(), fb) + } + } + } + } +} + +// TestWorkbenchNoWriteVerbs scans all non-test Go files in the workbench +// package for strings that suggest SSH/cPanel write operations. +func TestWorkbenchNoWriteVerbs(t *testing.T) { + forbiddenStrings := []string{ + "RunUAPI", + "RunAPI2", + "DialBoth", + "DialDest", + "DialSource", + "sshx.Dial", + "mass_edit_zone", + "add_forwarder", + "delete_forwarder", + "set_default_address", + "crontab", + } + + dir := "." + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { + continue + } + if strings.HasSuffix(e.Name(), "_test.go") { + continue + } + content, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + t.Fatal(err) + } + for _, verb := range forbiddenStrings { + if strings.Contains(string(content), verb) { + t.Errorf("%s contains forbidden write verb %q", e.Name(), verb) + } + } + } +} + +// TestSessionJSONNoCredentialFields verifies that the Session type's JSON +// serialization never includes fields that could contain SECRETS. +// +// Since the New Migration Wizard (setup flow) the session legitimately stores +// the NON-secret connection COORDINATES of a migration, host, port and the +// cPanel account (== user-level SSH user), under Session.Setup. Those are +// safe to persist and display; they are what makes a session self-describing. +// This guard therefore forbids only genuinely secret-bearing tags. Credentials +// (ssh_pass, tokens, keys) live ONLY in host.yaml (0600) and must never appear +// in a session json tag. A structural companion guard lives in setup_test.go +// (TestEndpointHasNoSecretField). +func TestSessionJSONNoCredentialFields(t *testing.T) { + credentialFields := []string{ + "password", "passwd", "ssh_pass", "token", "secret", + "ssh_key", "private_key", "apikey", "api_key", "credential", + } + + dir := "." + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { + continue + } + if strings.HasSuffix(e.Name(), "_test.go") { + continue + } + content, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + t.Fatal(err) + } + lower := strings.ToLower(string(content)) + for _, field := range credentialFields { + // Look for json tags that would serialize credential data + jsonTag := `json:"` + field + if strings.Contains(lower, jsonTag) { + t.Errorf("%s has json tag containing credential field %q", e.Name(), field) + } + } + } +} diff --git a/internal/workbench/scope_confirm_test.go b/internal/workbench/scope_confirm_test.go new file mode 100644 index 00000000..32547dba --- /dev/null +++ b/internal/workbench/scope_confirm_test.go @@ -0,0 +1,89 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench + +import ( + "testing" + "time" +) + +// ConfirmScope persists the content selection and marks the scope confirmed, +// recording a timeline event. Backward-compatible: a legacy session with no +// Setup gains one. +func TestConfirmScopePersists(t *testing.T) { + store, err := NewStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sess, err := store.Create("acmeweb", "src", "dst", time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if sess.Setup != nil { + t.Fatal("legacy create should have nil Setup") + } + + now := time.Now().UTC() + got, err := store.ConfirmScope(sess.ID, ContentSelection{Files: true, Databases: true}, now) + if err != nil { + t.Fatalf("ConfirmScope: %v", err) + } + if got.Setup == nil { + t.Fatal("legacy session must gain a Setup after ConfirmScope") + } + if got.Setup.ScopeConfirmedAt == nil || !got.Setup.ScopeConfirmedAt.Equal(now) { + t.Errorf("ScopeConfirmedAt = %v, want %v", got.Setup.ScopeConfirmedAt, now) + } + if !got.Setup.Content.Files || !got.Setup.Content.Databases { + t.Errorf("content not persisted: %+v", got.Setup.Content) + } + + // Reload from disk: it must survive a round-trip (backward-compatible JSON). + reloaded, err := store.Get(sess.ID) + if err != nil { + t.Fatal(err) + } + if reloaded.Setup == nil || reloaded.Setup.ScopeConfirmedAt == nil { + t.Error("scope confirmation must survive a disk round-trip") + } + // A timeline event records the confirmation. + found := false + for _, e := range reloaded.Timeline { + if e.Action == "scope_confirmed" { + found = true + } + } + if !found { + t.Error("ConfirmScope must record a scope_confirmed timeline event") + } +} + +// ConfirmScope on an existing Setup preserves the endpoints and only replaces +// the content + confirmation stamp. +func TestConfirmScopePreservesEndpoints(t *testing.T) { + store, err := NewStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + setup := &SetupMeta{ + PrimaryDomain: "example.com", + Source: Endpoint{Host: "1.2.3.4", Account: "src"}, + Destination: Endpoint{Host: "5.6.7.8", Account: "dst"}, + Content: ContentSelection{Files: true}, + } + sess, err := store.CreateWithSetup("acmeweb", "", "", setup, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + got, err := store.ConfirmScope(sess.ID, ContentSelection{Email: true, EmailConfig: true}, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if got.Setup.PrimaryDomain != "example.com" || got.Setup.Source.Host != "1.2.3.4" { + t.Error("ConfirmScope must preserve endpoints/domain") + } + if got.Setup.Content.Files || !got.Setup.Content.Email { + t.Errorf("content must be replaced by the confirmed selection, got %+v", got.Setup.Content) + } +} diff --git a/internal/workbench/setup_store_test.go b/internal/workbench/setup_store_test.go new file mode 100644 index 00000000..fdf70d8e --- /dev/null +++ b/internal/workbench/setup_store_test.go @@ -0,0 +1,72 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench + +import ( + "os" + "path/filepath" + "reflect" + "testing" + "time" +) + +func TestCreateWithSetupPersistsMetadata(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) + setup := &SetupMeta{ + PrimaryDomain: "example.com", + Notes: "test note", + Source: Endpoint{Host: "1.2.3.4", Port: 22, Account: "acmeweb"}, + Destination: Endpoint{Host: "5.6.7.8", Port: 22, Account: "acmeweb"}, + Content: ContentSelection{Files: true, Databases: true, DNS: false}, + } + sess, err := s.CreateWithSetup("acmeweb", "1.2.3.4", "5.6.7.8", setup, now) + if err != nil { + t.Fatal(err) + } + if sess.Status != StatusDraft || sess.CurrentStep != StepSetup { + t.Errorf("new session should start draft/setup, got %s/%s", sess.Status, sess.CurrentStep) + } + if sess.Setup == nil { + t.Fatal("Setup is nil after CreateWithSetup") + } + if !reflect.DeepEqual(*sess.Setup, *setup) { + t.Errorf("Setup mismatch:\n got=%+v\nwant=%+v", *sess.Setup, *setup) + } + + // Reload from disk: metadata must survive the round-trip. + got, err := s.Get(sess.ID) + if err != nil { + t.Fatal(err) + } + if got.Setup == nil || !reflect.DeepEqual(*got.Setup, *setup) { + t.Errorf("reloaded Setup mismatch: %+v", got.Setup) + } + + // The persisted file must be 0600 (same posture as writeSession). + info, err := os.Stat(filepath.Join(s.root, sess.ID, "session.json")) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("session.json perm = %o, want 0600", perm) + } +} + +// TestCreateWithSetupNilBehavesLikeCreate: passing nil setup yields a session +// with a nil Setup, CreateWithSetup(nil) is a superset of Create. +func TestCreateWithSetupNilSetup(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) + sess, err := s.CreateWithSetup("acct", "src", "dst", nil, now) + if err != nil { + t.Fatal(err) + } + if sess.Setup != nil { + t.Errorf("Setup = %+v, want nil", sess.Setup) + } + if sess.Name != "acct" || sess.SourceProfile != "src" || sess.DestinationProfile != "dst" { + t.Errorf("legacy fields wrong: %+v", sess) + } +} diff --git a/internal/workbench/setup_test.go b/internal/workbench/setup_test.go new file mode 100644 index 00000000..9e6c3703 --- /dev/null +++ b/internal/workbench/setup_test.go @@ -0,0 +1,92 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// TestSetupMetaRoundTrip pins the wizard metadata schema: a session with a +// non-nil Setup marshals and unmarshals byte-stably (no secret fields, DNS +// carried as its own selection flag). +func TestSetupMetaRoundTrip(t *testing.T) { + in := &SetupMeta{ + PrimaryDomain: "example.com", + Notes: "first test migration", + Source: Endpoint{Host: "192.168.1.193", Port: 22, Account: "acmeweb"}, + Destination: Endpoint{Host: "192.168.1.78", Port: 2222, Account: "acmeweb"}, + Content: ContentSelection{ + Files: true, Databases: true, Email: true, EmailConfig: false, Cron: true, DNS: false, + }, + } + b, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out SetupMeta + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !reflect.DeepEqual(*in, out) { + t.Errorf("round-trip mismatch:\n in=%+v\nout=%+v", *in, out) + } +} + +// TestEndpointHasNoSecretField is the structural anti-leak guard: an Endpoint +// must never gain a password/token/secret field. The session is persisted and +// can be bundled into reports, a secret here would leak by construction. +func TestEndpointHasNoSecretField(t *testing.T) { + tp := reflect.TypeOf(Endpoint{}) + for i := 0; i < tp.NumField(); i++ { + name := strings.ToLower(tp.Field(i).Name) + for _, bad := range []string{"pass", "secret", "token", "key", "cred"} { + if strings.Contains(name, bad) { + t.Errorf("Endpoint field %q looks secret-bearing (contains %q); credentials must stay in host.yaml only", tp.Field(i).Name, bad) + } + } + } +} + +// TestSessionSetupOptional proves an OLD session JSON (no "setup" key) still +// parses, Setup is a pointer and stays nil, so existing sessions keep working. +func TestSessionSetupOptional(t *testing.T) { + old := `{ + "id": "mig_20260101_deadbeefcafe", + "name": "legacy", + "source_profile": "src", + "destination_profile": "dst", + "status": "draft", + "current_step": "setup", + "artifacts": [], + "timeline": [], + "tool_version": "test" + }` + var s Session + if err := json.Unmarshal([]byte(old), &s); err != nil { + t.Fatalf("legacy session must still parse: %v", err) + } + if s.Setup != nil { + t.Errorf("legacy session Setup = %+v, want nil", s.Setup) + } + if s.Name != "legacy" { + t.Errorf("Name = %q, want legacy", s.Name) + } +} + +// TestSetupOmittedWhenNil ensures a session without a wizard setup does not +// emit an empty "setup" object (omitempty on the pointer keeps old sessions +// byte-clean). +func TestSetupOmittedWhenNil(t *testing.T) { + s := Session{ID: "mig_x", Name: "n", Status: StatusDraft} + b, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "\"setup\"") { + t.Errorf("nil Setup must be omitted from JSON, got: %s", b) + } +} diff --git a/internal/workbench/status.go b/internal/workbench/status.go new file mode 100644 index 00000000..4bf7479b --- /dev/null +++ b/internal/workbench/status.go @@ -0,0 +1,78 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench + +import "fmt" + +// terminalStatuses are states from which no normal transition is allowed +// (except -> archived). +var terminalStatuses = map[Status]bool{ + StatusCutoverDone: true, + StatusArchived: true, +} + +// activeStatuses are states from which blocked/failed are always reachable. +var activeStatuses map[Status]bool + +func init() { + activeStatuses = make(map[Status]bool) + for _, s := range AllStatuses { + if !terminalStatuses[s] && s != StatusBlocked && s != StatusFailed { + activeStatuses[s] = true + } + } +} + +// allowedTransitions defines the legal FORWARD transitions. The matrix is +// intentionally strict (no backward jumps), recovery scenarios (re-apply +// after failed verification, unblock after fix) use --force with a mandatory +// reason, which is recorded distinctly in the timeline as "forced_status_change". +// blocked and failed are reachable from any active status (handled separately). +// archived is reachable from cutover_done, blocked, and failed. +var allowedTransitions = map[Status][]Status{ + StatusDraft: {StatusPreflightRequired}, + StatusPreflightRequired: {StatusInventoryReady}, + StatusInventoryReady: {StatusChecklistReady}, + StatusChecklistReady: {StatusManualActionsRequired, StatusReadyForApply}, + StatusManualActionsRequired: {StatusReadyForApply}, + StatusReadyForApply: {StatusApplyInProgress}, + StatusApplyInProgress: {StatusApplyDone}, + StatusApplyDone: {StatusVerificationRequired}, + StatusVerificationRequired: {StatusReadyForCutover}, + StatusReadyForCutover: {StatusCutoverDone}, + StatusCutoverDone: {StatusArchived}, + StatusBlocked: {StatusArchived}, + StatusFailed: {StatusArchived}, +} + +// CanTransition reports whether from -> to is a legal status transition. +func CanTransition(from, to Status) bool { + if !ValidStatus(from) || !ValidStatus(to) { + return false + } + // blocked/failed reachable from any active status + if (to == StatusBlocked || to == StatusFailed) && activeStatuses[from] { + return true + } + for _, allowed := range allowedTransitions[from] { + if allowed == to { + return true + } + } + return false +} + +// ValidateTransition returns an error if from -> to is not a legal transition. +func ValidateTransition(from, to Status) error { + if !ValidStatus(from) { + return fmt.Errorf("invalid current status %q", from) + } + if !ValidStatus(to) { + return fmt.Errorf("invalid target status %q", to) + } + if !CanTransition(from, to) { + return fmt.Errorf("transition %q -> %q is not allowed", from, to) + } + return nil +} diff --git a/internal/workbench/status_test.go b/internal/workbench/status_test.go new file mode 100644 index 00000000..73df4636 --- /dev/null +++ b/internal/workbench/status_test.go @@ -0,0 +1,120 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench + +import "testing" + +func TestCanTransitionForwardPath(t *testing.T) { + // The happy path: draft -> preflight_required -> ... -> cutover_done -> archived + happy := []Status{ + StatusDraft, + StatusPreflightRequired, + StatusInventoryReady, + StatusChecklistReady, + StatusReadyForApply, + StatusApplyInProgress, + StatusApplyDone, + StatusVerificationRequired, + StatusReadyForCutover, + StatusCutoverDone, + StatusArchived, + } + for i := 0; i < len(happy)-1; i++ { + if !CanTransition(happy[i], happy[i+1]) { + t.Errorf("CanTransition(%q, %q) = false, want true", happy[i], happy[i+1]) + } + } +} + +func TestCanTransitionManualActionsPath(t *testing.T) { + if !CanTransition(StatusChecklistReady, StatusManualActionsRequired) { + t.Error("checklist_ready -> manual_actions_required should be allowed") + } + if !CanTransition(StatusManualActionsRequired, StatusReadyForApply) { + t.Error("manual_actions_required -> ready_for_apply should be allowed") + } +} + +func TestCanTransitionBlockedFromAnyActive(t *testing.T) { + active := []Status{ + StatusDraft, StatusPreflightRequired, StatusInventoryReady, + StatusChecklistReady, StatusManualActionsRequired, + StatusReadyForApply, StatusApplyInProgress, StatusApplyDone, + StatusVerificationRequired, StatusReadyForCutover, + } + for _, s := range active { + if !CanTransition(s, StatusBlocked) { + t.Errorf("CanTransition(%q, blocked) = false, want true", s) + } + if !CanTransition(s, StatusFailed) { + t.Errorf("CanTransition(%q, failed) = false, want true", s) + } + } +} + +func TestCanTransitionTerminalCannotGoBack(t *testing.T) { + // archived is fully terminal + for _, s := range AllStatuses { + if s == StatusArchived { + continue + } + if CanTransition(StatusArchived, s) { + t.Errorf("CanTransition(archived, %q) = true, want false", s) + } + } + // cutover_done can only go to archived + for _, s := range AllStatuses { + if s == StatusArchived { + continue + } + if CanTransition(StatusCutoverDone, s) { + t.Errorf("CanTransition(cutover_done, %q) = true, want false", s) + } + } +} + +func TestCanTransitionBlockedFailedToArchived(t *testing.T) { + if !CanTransition(StatusBlocked, StatusArchived) { + t.Error("blocked -> archived should be allowed") + } + if !CanTransition(StatusFailed, StatusArchived) { + t.Error("failed -> archived should be allowed") + } +} + +func TestCanTransitionNoBackwardJumps(t *testing.T) { + // No state can go backward to draft + for _, s := range AllStatuses { + if s == StatusDraft { + continue + } + if CanTransition(s, StatusDraft) { + t.Errorf("CanTransition(%q, draft) = true, backward jump", s) + } + } +} + +func TestCanTransitionInvalidStatuses(t *testing.T) { + if CanTransition("bogus", StatusDraft) { + t.Error("invalid from accepted") + } + if CanTransition(StatusDraft, "bogus") { + t.Error("invalid to accepted") + } +} + +func TestValidateTransitionReturnsError(t *testing.T) { + if err := ValidateTransition(StatusDraft, StatusPreflightRequired); err != nil { + t.Errorf("unexpected error: %v", err) + } + if err := ValidateTransition(StatusArchived, StatusDraft); err == nil { + t.Error("expected error for archived -> draft") + } + if err := ValidateTransition("bogus", StatusDraft); err == nil { + t.Error("expected error for invalid from") + } + if err := ValidateTransition(StatusDraft, "bogus"); err == nil { + t.Error("expected error for invalid to") + } +} diff --git a/internal/workbench/store.go b/internal/workbench/store.go new file mode 100644 index 00000000..d2592f13 --- /dev/null +++ b/internal/workbench/store.go @@ -0,0 +1,374 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/tis24dev/cPanel_self-migration/internal/version" +) + +var ( + ErrSessionNotFound = errors.New("session not found") + ErrInvalidSessionID = errors.New("invalid session id") + ErrInvalidStatus = errors.New("invalid status") + ErrTransitionDenied = errors.New("transition not allowed") + ErrUnknownArtifactKind = errors.New("unknown artifact kind") +) + +// Store manages migration sessions on the local filesystem. +// Sessions are stored as individual JSON files under root//session.json. +// All writes are atomic (write-temp + fsync + rename). The mutex serializes +// in-process access; flock on a lock file serializes cross-process access +// (safe for concurrent ui server + CLI invocations on the same store). +type Store struct { + mu sync.Mutex + root string +} + +// NewStore creates a Store rooted at dir, creating the directory with 0700 +// permissions if it does not exist. +func NewStore(dir string) (*Store, error) { + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("create store dir: %w", err) + } + return &Store{root: dir}, nil +} + +// Create initializes a new migration session and persists it. +func (s *Store) Create(name, sourceProfile, destProfile string, now time.Time) (*Session, error) { + return s.CreateWithSetup(name, sourceProfile, destProfile, nil, now) +} + +// CreateWithSetup initializes a new migration session, optionally attaching the +// non-secret wizard metadata (setup may be nil, in which case it behaves +// exactly like Create). The setup carries no credentials, those live only in +// host.yaml, so nothing secret is ever written to session.json. +func (s *Store) CreateWithSetup(name, sourceProfile, destProfile string, setup *SetupMeta, now time.Time) (*Session, error) { + fl, err := s.lockFile() + if err != nil { + return nil, err + } + defer unlockFile(fl) + s.mu.Lock() + defer s.mu.Unlock() + + id, err := generateID(now) + if err != nil { + return nil, err + } + + sessDir := filepath.Join(s.root, id) + if err := os.Mkdir(sessDir, 0700); err != nil { + if os.IsExist(err) { + return nil, fmt.Errorf("session id collision %q (retry)", id) + } + return nil, fmt.Errorf("create session dir: %w", err) + } + artifactDir := filepath.Join(sessDir, "artifacts") + if err := os.Mkdir(artifactDir, 0700); err != nil { + return nil, fmt.Errorf("create artifact dir: %w", err) + } + + sess := &Session{ + ID: id, + Name: name, + SourceProfile: sourceProfile, + DestinationProfile: destProfile, + Status: StatusDraft, + CurrentStep: StepSetup, + ArtifactDir: artifactDir, + CreatedAt: now, + UpdatedAt: now, + LastError: "", + Artifacts: []ArtifactEntry{}, + Timeline: []TimelineEvent{}, + ToolVersion: version.String(), + Setup: setup, + } + + if err := s.writeSession(id, sess); err != nil { + _ = os.RemoveAll(sessDir) // best-effort cleanup of the half-created session dir + return nil, err + } + return sess, nil +} + +// List returns all sessions ordered by created_at ascending, plus any +// warnings for corrupted/skipped entries. Callers decide how to surface warnings. +func (s *Store) List() ([]Session, []string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := os.ReadDir(s.root) + if err != nil { + return nil, nil, fmt.Errorf("read store dir: %w", err) + } + + var sessions []Session + var warnings []string + for _, e := range entries { + if !e.IsDir() { + continue + } + sess, err := s.readSession(e.Name()) + if err != nil { + warnings = append(warnings, fmt.Sprintf("skipping corrupted session %q: %v", e.Name(), err)) + continue + } + sessions = append(sessions, *sess) + } + + sort.Slice(sessions, func(i, j int) bool { + if sessions[i].CreatedAt.Equal(sessions[j].CreatedAt) { + return sessions[i].ID < sessions[j].ID + } + return sessions[i].CreatedAt.Before(sessions[j].CreatedAt) + }) + + if sessions == nil { + sessions = []Session{} + } + return sessions, warnings, nil +} + +// Get retrieves a single session by ID. +func (s *Store) Get(id string) (*Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.readSession(id) +} + +// SetStatus transitions a session to a new status. If force is true, the +// transition matrix is bypassed but a non-empty reason is required. The +// transition is recorded in the session timeline. +func (s *Store) SetStatus(id string, to Status, force bool, reason string, now time.Time) (*Session, error) { + if !ValidStatus(to) { + return nil, fmt.Errorf("invalid target status %q", to) + } + if force { + trimmed := strings.TrimSpace(reason) + if len(trimmed) < 10 { + return nil, fmt.Errorf("--force requires a reason of at least 10 characters (got %d)", len(trimmed)) + } + } + + fl, flErr := s.lockFile() + if flErr != nil { + return nil, flErr + } + defer unlockFile(fl) + s.mu.Lock() + defer s.mu.Unlock() + + sess, err := s.readSession(id) + if err != nil { + return nil, err + } + + if !force { + if err := ValidateTransition(sess.Status, to); err != nil { + return nil, err + } + } + + event := TimelineEvent{ + Timestamp: now, + Action: "status_change", + FromStatus: sess.Status, + ToStatus: to, + Reason: reason, + ToolVersion: version.String(), + } + if force { + event.Action = "forced_status_change" + } + + sess.Status = to + sess.UpdatedAt = now + sess.Timeline = append(sess.Timeline, event) + + if err := s.writeSession(id, sess); err != nil { + return nil, err + } + return sess, nil +} + +// ConfirmScope updates the migration content selection and marks the scope as +// confirmed after the preflight (Fase 2). It is a session METADATA mutation, not +// a migration write, no credentials, no artifact, no engine call. A legacy +// session (nil Setup) gains a Setup carrying the chosen content. The change is +// recorded in the timeline. The caller (webui) is responsible for the product +// gate "editable only before the first apply/write". +func (s *Store) ConfirmScope(id string, content ContentSelection, now time.Time) (*Session, error) { + fl, err := s.lockFile() + if err != nil { + return nil, err + } + defer unlockFile(fl) + s.mu.Lock() + defer s.mu.Unlock() + + sess, err := s.readSession(id) + if err != nil { + return nil, err + } + + if sess.Setup == nil { + sess.Setup = &SetupMeta{} + } + sess.Setup.Content = content + confirmed := now + sess.Setup.ScopeConfirmedAt = &confirmed + sess.UpdatedAt = now + sess.Timeline = append(sess.Timeline, TimelineEvent{ + Timestamp: now, + Action: "scope_confirmed", + Reason: scopeSummary(content), + ToolVersion: version.String(), + }) + + if err := s.writeSession(id, sess); err != nil { + return nil, err + } + return sess, nil +} + +// scopeSummary builds a short, non-secret description of the confirmed areas for +// the timeline reason. +func scopeSummary(c ContentSelection) string { + var areas []string + if c.Files { + areas = append(areas, "files") + } + if c.Databases { + areas = append(areas, "databases") + } + if c.Email { + areas = append(areas, "email") + } + if c.EmailConfig { + areas = append(areas, "email config") + } + if c.Cron { + areas = append(areas, "cron") + } + if c.DNS { + areas = append(areas, "DNS (manual)") + } + if len(areas) == 0 { + return "scope confirmed: no areas" + } + return "scope confirmed: " + strings.Join(areas, ", ") +} + +// writeSession atomically persists the session (write-temp + rename). +// The folderID parameter is the validated directory name, never derived +// from sess.ID to prevent path traversal via crafted JSON content. +func (s *Store) writeSession(folderID string, sess *Session) error { + if !isCleanID(folderID) { + return fmt.Errorf("invalid folder id %q", folderID) + } + sessDir := filepath.Join(s.root, folderID) + if err := os.MkdirAll(sessDir, 0700); err != nil { + return fmt.Errorf("ensure session dir: %w", err) + } + + data, err := json.MarshalIndent(sess, "", " ") + if err != nil { + return fmt.Errorf("marshal session: %w", err) + } + data = append(data, '\n') + + target := filepath.Join(sessDir, "session.json") + tmp, err := os.CreateTemp(sessDir, "session-*.json.tmp") + if err != nil { + return fmt.Errorf("create temp session: %w", err) + } + tmpName := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return fmt.Errorf("write temp session: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return fmt.Errorf("sync temp session: %w", err) + } + if err := tmp.Chmod(0600); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return fmt.Errorf("chmod temp session: %w", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return fmt.Errorf("close temp session: %w", err) + } + if err := os.Rename(tmpName, target); err != nil { + _ = os.Remove(tmpName) + return fmt.Errorf("rename session: %w", err) + } + return nil +} + +// readSession loads a session from disk. +func (s *Store) readSession(id string) (*Session, error) { + if !isCleanID(id) { + return nil, fmt.Errorf("%w: %q", ErrInvalidSessionID, id) + } + root, err := os.OpenRoot(s.root) + if err != nil { + return nil, fmt.Errorf("open store root: %w", err) + } + defer func() { _ = root.Close() }() + data, err := root.ReadFile(filepath.Join(id, "session.json")) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("session %q: %w", id, ErrSessionNotFound) + } + return nil, fmt.Errorf("read session %q: %w", id, err) + } + var sess Session + if err := json.Unmarshal(data, &sess); err != nil { + return nil, fmt.Errorf("parse session %q: %w", id, err) + } + if sess.ID != id { + return nil, fmt.Errorf("session %q: id field mismatch (found %q)", id, sess.ID) + } + return &sess, nil +} + +// generateID creates a stable session ID in the format mig_YYYYMMDD_. +func generateID(now time.Time) (string, error) { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate id: %w", err) + } + return fmt.Sprintf("mig_%s_%s", now.UTC().Format("20060102"), hex.EncodeToString(b)), nil +} + +// isCleanID validates that an ID contains no path separators or traversal. +func isCleanID(id string) bool { + if id == "" || id == "." || id == ".." { + return false + } + for _, c := range id { + if c == '/' || c == '\\' || c == 0 { + return false + } + } + return filepath.Base(id) == id +} diff --git a/internal/workbench/store_test.go b/internal/workbench/store_test.go new file mode 100644 index 00000000..5e8f696d --- /dev/null +++ b/internal/workbench/store_test.go @@ -0,0 +1,308 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + s, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestNewStoreCreatesDirectoryWithPermissions(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "migrations") + _, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0700 { + t.Errorf("root dir perm = %o, want 0700", perm) + } +} + +func TestCreateSession(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, err := s.Create("acme-web", "old193", "new78", now) + if err != nil { + t.Fatal(err) + } + if sess.ID == "" { + t.Fatal("session ID is empty") + } + if sess.Name != "acme-web" { + t.Errorf("name = %q, want acme-web", sess.Name) + } + if sess.SourceProfile != "old193" { + t.Errorf("source = %q, want old193", sess.SourceProfile) + } + if sess.DestinationProfile != "new78" { + t.Errorf("dest = %q, want new78", sess.DestinationProfile) + } + if sess.Status != StatusDraft { + t.Errorf("status = %q, want draft", sess.Status) + } + if sess.CurrentStep != StepSetup { + t.Errorf("step = %q, want setup", sess.CurrentStep) + } + if !sess.CreatedAt.Equal(now) { + t.Errorf("created_at = %v, want %v", sess.CreatedAt, now) + } + if sess.Artifacts == nil { + t.Error("artifacts is nil, want empty slice") + } + if sess.Timeline == nil { + t.Error("timeline is nil, want empty slice") + } + + // Session directory exists with 0700 + info, err := os.Stat(sess.ArtifactDir) + if err != nil { + t.Fatalf("artifact dir: %v", err) + } + if perm := info.Mode().Perm(); perm != 0700 { + t.Errorf("artifact dir perm = %o, want 0700", perm) + } + + // session.json exists with 0600 + sjPath := filepath.Join(s.root, sess.ID, "session.json") + info, err = os.Stat(sjPath) + if err != nil { + t.Fatalf("session.json: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("session.json perm = %o, want 0600", perm) + } +} + +func TestListSessionsDeterministicOrder(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + _, _ = s.Create("beta", "src", "dst", now.Add(1*time.Second)) + _, _ = s.Create("alpha", "src", "dst", now) + _, _ = s.Create("gamma", "src", "dst", now.Add(2*time.Second)) + + list, warnings, err := s.List() + if err != nil { + t.Fatal(err) + } + if len(warnings) != 0 { + t.Errorf("unexpected warnings: %v", warnings) + } + if len(list) != 3 { + t.Fatalf("len = %d, want 3", len(list)) + } + // Ordered by created_at ascending + if list[0].Name != "alpha" { + t.Errorf("list[0].Name = %q, want alpha", list[0].Name) + } + if list[1].Name != "beta" { + t.Errorf("list[1].Name = %q, want beta", list[1].Name) + } + if list[2].Name != "gamma" { + t.Errorf("list[2].Name = %q, want gamma", list[2].Name) + } + + // Second call: same order + list2, _, _ := s.List() + for i := range list { + if list[i].ID != list2[i].ID { + t.Errorf("list order unstable at %d", i) + } + } +} + +func TestGetSession(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + created, _ := s.Create("test", "src", "dst", now) + + got, err := s.Get(created.ID) + if err != nil { + t.Fatal(err) + } + if got.ID != created.ID { + t.Errorf("ID mismatch") + } + if got.Name != "test" { + t.Errorf("Name = %q, want test", got.Name) + } +} + +func TestGetSessionNotFound(t *testing.T) { + s := newTestStore(t) + _, err := s.Get("nonexistent") + if err == nil { + t.Fatal("expected error for missing session") + } + if !errors.Is(err, ErrSessionNotFound) { + t.Errorf("error = %v, want ErrSessionNotFound", err) + } +} + +func TestGetSessionInvalidID(t *testing.T) { + s := newTestStore(t) + _, err := s.Get("../etc/passwd") + if err == nil { + t.Fatal("expected error for invalid id") + } + if !errors.Is(err, ErrInvalidSessionID) { + t.Errorf("error = %v, want ErrInvalidSessionID", err) + } +} + +func TestUpdateStatus(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + updated, err := s.SetStatus(sess.ID, StatusPreflightRequired, false, "", now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + if updated.Status != StatusPreflightRequired { + t.Errorf("status = %q, want preflight_required", updated.Status) + } + if !updated.UpdatedAt.Equal(now.Add(time.Minute)) { + t.Error("updated_at not bumped") + } + if len(updated.Timeline) != 1 { + t.Fatalf("timeline len = %d, want 1", len(updated.Timeline)) + } + if updated.Timeline[0].FromStatus != StatusDraft { + t.Errorf("timeline from = %q", updated.Timeline[0].FromStatus) + } + if updated.Timeline[0].ToStatus != StatusPreflightRequired { + t.Errorf("timeline to = %q", updated.Timeline[0].ToStatus) + } +} + +func TestUpdateStatusRejectsInvalid(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + _, err := s.SetStatus(sess.ID, "bogus", false, "", now) + if err == nil { + t.Fatal("expected error for invalid status") + } +} + +func TestUpdateStatusRejectsIllegalTransition(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + // draft -> cutover_done is not legal + _, err := s.SetStatus(sess.ID, StatusCutoverDone, false, "", now) + if err == nil { + t.Fatal("expected error for illegal transition") + } +} + +func TestUpdateStatusForceBypassesTransitionMatrix(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + // draft -> cutover_done would normally fail, but force allows it + updated, err := s.SetStatus(sess.ID, StatusCutoverDone, true, "emergency override", now.Add(time.Minute)) + if err != nil { + t.Fatalf("force transition failed: %v", err) + } + if updated.Status != StatusCutoverDone { + t.Errorf("status = %q, want cutover_done", updated.Status) + } + if updated.Timeline[0].Reason != "emergency override" { + t.Errorf("reason = %q", updated.Timeline[0].Reason) + } +} + +func TestUpdateStatusForceRequiresReason(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + // Empty reason + _, err := s.SetStatus(sess.ID, StatusCutoverDone, true, "", now) + if err == nil { + t.Fatal("expected error when force without reason") + } + // Too short reason + _, err = s.SetStatus(sess.ID, StatusCutoverDone, true, "short", now) + if err == nil { + t.Fatal("expected error when force with too-short reason") + } + // Whitespace-only reason + _, err = s.SetStatus(sess.ID, StatusCutoverDone, true, " ", now) + if err == nil { + t.Fatal("expected error when force with whitespace-only reason") + } +} + +func TestArchiveSession(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + // Move to a state that can reach archived: use force + s.SetStatus(sess.ID, StatusCutoverDone, true, "test override for archive", now) + + updated, err := s.SetStatus(sess.ID, StatusArchived, false, "", now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + if updated.Status != StatusArchived { + t.Errorf("status = %q, want archived", updated.Status) + } +} + +func TestSessionJSONNoNullArrays(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + raw, err := os.ReadFile(filepath.Join(s.root, sess.ID, "session.json")) + if err != nil { + t.Fatal(err) + } + + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + // artifacts and timeline must be [] not null + if m["artifacts"] == nil { + t.Error("artifacts is null in JSON, want []") + } + if m["timeline"] == nil { + t.Error("timeline is null in JSON, want []") + } +} + +func TestSessionIDStable(t *testing.T) { + s := newTestStore(t) + now := time.Date(2026, 7, 4, 10, 0, 0, 0, time.UTC) + sess, _ := s.Create("test", "src", "dst", now) + + got, _ := s.Get(sess.ID) + if got.ID != sess.ID { + t.Error("ID changed after read-back") + } +} diff --git a/internal/workbench/types.go b/internal/workbench/types.go new file mode 100644 index 00000000..5fee5126 --- /dev/null +++ b/internal/workbench/types.go @@ -0,0 +1,236 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench + +import "time" + +// Status represents the lifecycle state of a migration session. +type Status string + +const ( + StatusDraft Status = "draft" + StatusPreflightRequired Status = "preflight_required" + StatusInventoryReady Status = "inventory_ready" + StatusChecklistReady Status = "checklist_ready" + StatusManualActionsRequired Status = "manual_actions_required" + StatusReadyForApply Status = "ready_for_apply" + StatusApplyInProgress Status = "apply_in_progress" + StatusApplyDone Status = "apply_done" + StatusVerificationRequired Status = "verification_required" + StatusReadyForCutover Status = "ready_for_cutover" + StatusCutoverDone Status = "cutover_done" + StatusBlocked Status = "blocked" + StatusFailed Status = "failed" + StatusArchived Status = "archived" +) + +// AllStatuses is the canonical list of valid statuses, in lifecycle order. +var AllStatuses = []Status{ + StatusDraft, + StatusPreflightRequired, + StatusInventoryReady, + StatusChecklistReady, + StatusManualActionsRequired, + StatusReadyForApply, + StatusApplyInProgress, + StatusApplyDone, + StatusVerificationRequired, + StatusReadyForCutover, + StatusCutoverDone, + StatusBlocked, + StatusFailed, + StatusArchived, +} + +// ValidStatus reports whether s is a known status. +func ValidStatus(s Status) bool { + for _, v := range AllStatuses { + if v == s { + return true + } + } + return false +} + +// Step represents the current operational step within a migration session. +type Step string + +const ( + StepSetup Step = "setup" + StepPreflight Step = "preflight" + StepInventory Step = "inventory" + StepDiffPolicyChecklist Step = "diff_policy_checklist" + StepPlanning Step = "planning" + StepApplyCore Step = "apply_core" + StepApplyEmail Step = "apply_email" + StepApplyDNS Step = "apply_dns" + StepApplyCron Step = "apply_cron" + StepVerify Step = "verify" + StepCutover Step = "cutover" + StepArchive Step = "archive" +) + +// AllSteps is the canonical ordered list of operational steps. +var AllSteps = []Step{ + StepSetup, + StepPreflight, + StepInventory, + StepDiffPolicyChecklist, + StepPlanning, + StepApplyCore, + StepApplyEmail, + StepApplyDNS, + StepApplyCron, + StepVerify, + StepCutover, + StepArchive, +} + +// ValidStep reports whether s is a known step. +func ValidStep(s Step) bool { + for _, v := range AllSteps { + if v == s { + return true + } + } + return false +} + +// ArtifactKind identifies the type of artifact attached to a session. +type ArtifactKind string + +const ( + ArtifactInventorySource ArtifactKind = "inventory_source" + ArtifactInventoryDestination ArtifactKind = "inventory_destination" + ArtifactInventoryDiff ArtifactKind = "inventory_diff" + ArtifactPolicyReport ArtifactKind = "policy_report" + ArtifactDNSPlan ArtifactKind = "dns_plan" + ArtifactMigrationChecklist ArtifactKind = "migration_checklist" + ArtifactAcceptances ArtifactKind = "acceptances" + ArtifactApplyReport ArtifactKind = "apply_report" + ArtifactDNSApplyReport ArtifactKind = "dns_apply_report" + ArtifactDNSVerifyReport ArtifactKind = "dns_verify_report" + ArtifactEmailPlan ArtifactKind = "email_plan" + ArtifactEmailApplyReport ArtifactKind = "email_apply_report" + ArtifactEmailVerifyReport ArtifactKind = "email_verify_report" + ArtifactCronPlan ArtifactKind = "cron_plan" + ArtifactCronApplyReport ArtifactKind = "cron_apply_report" + ArtifactCronVerifyReport ArtifactKind = "cron_verify_report" + ArtifactEventsJSONL ArtifactKind = "events_jsonl" +) + +// AllArtifactKinds is the canonical list of allowed artifact kinds. +var AllArtifactKinds = []ArtifactKind{ + ArtifactInventorySource, + ArtifactInventoryDestination, + ArtifactInventoryDiff, + ArtifactPolicyReport, + ArtifactDNSPlan, + ArtifactMigrationChecklist, + ArtifactAcceptances, + ArtifactApplyReport, + ArtifactDNSApplyReport, + ArtifactDNSVerifyReport, + ArtifactEmailPlan, + ArtifactEmailApplyReport, + ArtifactEmailVerifyReport, + ArtifactCronPlan, + ArtifactCronApplyReport, + ArtifactCronVerifyReport, + ArtifactEventsJSONL, +} + +// ValidArtifactKind reports whether k is a known artifact kind. +func ValidArtifactKind(k ArtifactKind) bool { + for _, v := range AllArtifactKinds { + if v == k { + return true + } + } + return false +} + +// ArtifactEntry records a single attached artifact within a session. +type ArtifactEntry struct { + Kind ArtifactKind `json:"kind"` + Path string `json:"path"` + SHA256 string `json:"sha256"` + AttachedAt time.Time `json:"attached_at"` +} + +// TimelineEvent records a state change or significant action within a session. +type TimelineEvent struct { + Timestamp time.Time `json:"timestamp"` + Action string `json:"action"` + FromStatus Status `json:"from_status,omitempty"` + ToStatus Status `json:"to_status,omitempty"` + Reason string `json:"reason,omitempty"` + ToolVersion string `json:"tool_version"` +} + +// Endpoint records the NON-SECRET coordinates of one cPanel host in a +// migration: where it is and which account to operate on. It carries NO +// password/token field ON PURPOSE, the session is persisted to disk and may +// be bundled into a report/archive, so a secret here would leak by +// construction. Credentials live only in host.yaml (0600). Account is the +// cPanel account user, which in this tool's model is the same as the +// user-level SSH user (there is no separate account field in the config). +type Endpoint struct { + Host string `json:"host,omitempty"` // IP or hostname + Port int `json:"port,omitempty"` // SSH port (default 22) + Account string `json:"account,omitempty"` // cPanel account == user-level SSH user +} + +// ContentSelection records which content areas the operator chose to migrate. +// DNS is DELIBERATELY a separate flag, never implied by a bulk "migrate +// everything" gesture (the wizard has no such gesture): touching DNS can reach +// production nameservers, so it must be an explicit, isolated choice. +type ContentSelection struct { + Files bool `json:"files"` + Databases bool `json:"databases"` + Email bool `json:"email"` + EmailConfig bool `json:"email_config"` + Cron bool `json:"cron"` + DNS bool `json:"dns"` +} + +// SetupMeta is the operator-facing definition of a migration captured by the +// New Migration Wizard: which account, from where, to where, and what to move. +// Every field is non-secret and safe to persist and display. It is optional (a +// pointer on Session, json omitempty) so sessions created before the wizard, +// which have no "setup" key, stay readable and stay nil. +type SetupMeta struct { + PrimaryDomain string `json:"primary_domain,omitempty"` + Notes string `json:"notes,omitempty"` + Source Endpoint `json:"source"` + Destination Endpoint `json:"destination"` + Content ContentSelection `json:"content"` + // ScopeConfirmedAt is set when the operator confirms/refines the migration + // scope AFTER the preflight (Fase 2). Nil = scope chosen in the wizard but + // not yet confirmed post-preflight, or a legacy session. omitempty keeps old + // session.json readable and unchanged. + ScopeConfirmedAt *time.Time `json:"scope_confirmed_at,omitempty"` +} + +// Session represents a single migration session, the governance envelope +// around one account migration from source to destination. +type Session struct { + ID string `json:"id"` + Name string `json:"name"` + SourceProfile string `json:"source_profile"` + DestinationProfile string `json:"destination_profile"` + Status Status `json:"status"` + CurrentStep Step `json:"current_step"` + ArtifactDir string `json:"artifact_dir"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastError string `json:"last_error"` + Artifacts []ArtifactEntry `json:"artifacts"` + Timeline []TimelineEvent `json:"timeline"` + ToolVersion string `json:"tool_version"` + // Setup is the non-secret migration definition from the New Migration + // Wizard. Nil for sessions created before the wizard or via the legacy + // name/source/destination create path. + Setup *SetupMeta `json:"setup,omitempty"` +} diff --git a/internal/workbench/types_test.go b/internal/workbench/types_test.go new file mode 100644 index 00000000..b5e19c0f --- /dev/null +++ b/internal/workbench/types_test.go @@ -0,0 +1,87 @@ +// Copyright (C) 2026 tis24dev +// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 + +package workbench + +import "testing" + +func TestValidStatus(t *testing.T) { + for _, s := range AllStatuses { + if !ValidStatus(s) { + t.Errorf("ValidStatus(%q) = false, want true", s) + } + } + invalids := []Status{"", "unknown", "DRAFT", "Draft", "ready"} + for _, s := range invalids { + if ValidStatus(s) { + t.Errorf("ValidStatus(%q) = true, want false", s) + } + } +} + +func TestAllStatusesNoDuplicates(t *testing.T) { + seen := map[Status]bool{} + for _, s := range AllStatuses { + if seen[s] { + t.Fatalf("duplicate status %q in AllStatuses", s) + } + seen[s] = true + } + if len(AllStatuses) != 14 { + t.Fatalf("len(AllStatuses) = %d, want 14", len(AllStatuses)) + } +} + +func TestValidStep(t *testing.T) { + for _, s := range AllSteps { + if !ValidStep(s) { + t.Errorf("ValidStep(%q) = false, want true", s) + } + } + invalids := []Step{"", "unknown", "SETUP", "Setup"} + for _, s := range invalids { + if ValidStep(s) { + t.Errorf("ValidStep(%q) = true, want false", s) + } + } +} + +func TestAllStepsNoDuplicates(t *testing.T) { + seen := map[Step]bool{} + for _, s := range AllSteps { + if seen[s] { + t.Fatalf("duplicate step %q in AllSteps", s) + } + seen[s] = true + } + if len(AllSteps) != 12 { + t.Fatalf("len(AllSteps) = %d, want 12", len(AllSteps)) + } +} + +func TestValidArtifactKind(t *testing.T) { + for _, k := range AllArtifactKinds { + if !ValidArtifactKind(k) { + t.Errorf("ValidArtifactKind(%q) = false, want true", k) + } + } + invalids := []ArtifactKind{"", "unknown", "host_yaml", "backup_raw"} + for _, k := range invalids { + if ValidArtifactKind(k) { + t.Errorf("ValidArtifactKind(%q) = true, want false", k) + } + } +} + +func TestAllArtifactKindsNoDuplicates(t *testing.T) { + seen := map[ArtifactKind]bool{} + for _, k := range AllArtifactKinds { + if seen[k] { + t.Fatalf("duplicate kind %q in AllArtifactKinds", k) + } + seen[k] = true + } + if len(AllArtifactKinds) != 17 { + t.Fatalf("len(AllArtifactKinds) = %d, want 17", len(AllArtifactKinds)) + } +} diff --git a/internal/wpconfig/ambiguity.go b/internal/wpconfig/ambiguity.go index 1597539d..7d7a9bf1 100644 --- a/internal/wpconfig/ambiguity.go +++ b/internal/wpconfig/ambiguity.go @@ -166,7 +166,7 @@ func CheckQuotedCutover(content string, anchor *regexp.Regexp, bind Bind, requir return Ambiguity{true, fmt.Sprintf("the %s PHP binds has a non-literal value resolved at runtime; the rewritten literal may not be what PHP uses", label)} } if v != blindVal { - return Ambiguity{true, fmt.Sprintf("the %s PHP binds resolves to a different value than the rewrite targeted (a decoy — heredoc/HTML/comment or a non-property assignment — was edited instead of the live one)", label)} + return Ambiguity{true, fmt.Sprintf("the %s PHP binds resolves to a different value than the rewrite targeted (a decoy, heredoc/HTML/comment or a non-property assignment, was edited instead of the live one)", label)} } return Ambiguity{} }