package audita import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "time" configkit "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" "gitea.maximumdirect.net/eric/narratio/internal/fileops" ) // MaxProcessedOutputBytes bounds Audita's processed-transcript JSON result. const MaxProcessedOutputBytes int64 = 64 * 1024 * 1024 // MaxReportOutputBytes bounds each optional Audita report. const MaxReportOutputBytes int64 = 16 * 1024 * 1024 // SubprocessRunnerConfig defines deterministic settings for Audita CLI execution. type SubprocessRunnerConfig struct { Binary string Timeout time.Duration Credentials []configkit.Secret Modules []string Overlay string ProposalProfile string ValidationProfile string TranscriptDescription string ConfigPath string OutputSchema string } // SubprocessRunner invokes Audita via subprocess. type SubprocessRunner struct { cfg SubprocessRunnerConfig run func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error) } // NewSubprocessRunner constructs a validated Audita subprocess runner. func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error) { if strings.TrimSpace(cfg.Binary) == "" { return nil, fmt.Errorf("audita binary is required") } if cfg.Timeout <= 0 { return nil, fmt.Errorf("audita timeout must be > 0") } for i, module := range cfg.Modules { if strings.TrimSpace(module) == "" { return nil, fmt.Errorf("audita module at index %d is empty", i) } } switch strings.TrimSpace(cfg.OutputSchema) { case "", "bare-segments", "audita-v1": default: return nil, fmt.Errorf("audita output schema must be one of: bare-segments, audita-v1") } cfg.Binary = strings.TrimSpace(cfg.Binary) cfg.Credentials = append([]configkit.Secret(nil), cfg.Credentials...) cfg.Modules = cloneModules(cfg.Modules) for i := range cfg.Modules { cfg.Modules[i] = strings.TrimSpace(cfg.Modules[i]) } return &SubprocessRunner{cfg: cfg, run: subprocess.Run}, nil } func cloneModules(modules []string) []string { if modules == nil { return nil } return append([]string{}, modules...) } // Run executes Audita process and validates its corrected transcript. func (r *SubprocessRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, error) { if r == nil { return PolishResult{}, fmt.Errorf("audita subprocess runner is nil") } for _, field := range []struct{ name, value string }{ {"merged transcript path", req.MergedTranscriptPath}, {"glossary path", req.GlossaryPath}, {"output processed path", req.OutputProcessedPath}, } { if strings.TrimSpace(field.value) == "" { return PolishResult{}, fmt.Errorf("audita %s is required", field.name) } } modules := req.Modules if modules == nil { modules = r.cfg.Modules } for i, module := range modules { if strings.TrimSpace(module) == "" { return PolishResult{}, fmt.Errorf("audita module at index %d is empty", i) } } modules = cloneModules(modules) for i := range modules { modules[i] = strings.TrimSpace(modules[i]) } args := r.buildArgs(req, modules) env, names, err := subprocess.CredentialEnvironment(r.cfg.Credentials) if err != nil { return PolishResult{}, fmt.Errorf("audita credentials: %w", err) } if req.GeneratedConfigPath != "" { if err := r.writeInvocationConfig(req, args, modules, names); err != nil { return PolishResult{}, fmt.Errorf("write audita invocation config %q: %w", req.GeneratedConfigPath, err) } } run := r.run if run == nil { run = subprocess.Run } runRes, runErr := run(ctx, subprocess.RunRequest{ Executable: r.cfg.Binary, Args: args, Timeout: r.cfg.Timeout, EnvOverrides: env, SensitiveEnvNames: names, DiagnosticOwner: "audita", StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, }) result := r.result(req, modules, names, runRes) if runErr != nil { result.Reports, _ = discoverReports(req.OutputProcessedPath) return result, fmt.Errorf("run audita process (binary=%q, output_dir=%q, stdout_log=%q, stderr_log=%q): %w", r.cfg.Binary, filepath.Dir(req.OutputProcessedPath), req.StdoutLogPath, req.StderrLogPath, runErr) } if err := validateProcessedOutput(req.OutputProcessedPath); err != nil { result.Reports, _ = discoverReports(req.OutputProcessedPath) return result, fmt.Errorf("validate audita processed output %q: %w", req.OutputProcessedPath, err) } reports, err := discoverReports(req.OutputProcessedPath) if err != nil { return result, err } result.Reports = reports return result, nil } func (r *SubprocessRunner) result(req PolishRequest, modules, names []string, runRes subprocess.RunResult) PolishResult { return PolishResult{ ProcessedTranscriptPath: req.OutputProcessedPath, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath, GeneratedConfigPath: req.GeneratedConfigPath, ExitCode: runRes.ExitCode, Duration: runRes.Duration, InvokedBinary: r.cfg.Binary, Metadata: map[string]any{ "adapter": "audita_subprocess", "modules": modules, "overlay": r.cfg.Overlay, "proposal_profile": r.cfg.ProposalProfile, "validation_profile": r.cfg.ValidationProfile, "transcript_description": r.cfg.TranscriptDescription, "config_path": r.cfg.ConfigPath, "output_schema": r.cfg.OutputSchema, "credential_names": names, }, } } func (r *SubprocessRunner) buildArgs(req PolishRequest, modules []string) []string { args := []string{"process", req.MergedTranscriptPath, "--glossary", req.GlossaryPath, "--output", req.OutputProcessedPath} if modules != nil { args = append(args, "--modules", strings.Join(modules, ",")) } for _, setting := range []struct{ flag, value string }{ {"--config", r.cfg.ConfigPath}, {"--overlay", r.cfg.Overlay}, {"--proposal-profile", r.cfg.ProposalProfile}, {"--validation-profile", r.cfg.ValidationProfile}, {"--transcript-description", r.cfg.TranscriptDescription}, {"--output-schema", r.cfg.OutputSchema}, } { if setting.value != "" { args = append(args, setting.flag, setting.value) } } return args } func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args, modules, names []string) error { return writeGeneratedYAML(req.GeneratedConfigPath, map[string]any{ "schema": "audita.generated.v2", "binary": r.cfg.Binary, "args": args, "timeout": r.cfg.Timeout.String(), "modules": modules, "overlay": r.cfg.Overlay, "proposal_profile": r.cfg.ProposalProfile, "validation_profile": r.cfg.ValidationProfile, "transcript_description": r.cfg.TranscriptDescription, "config_path": r.cfg.ConfigPath, "output_schema": r.cfg.OutputSchema, "merged_transcript_path": req.MergedTranscriptPath, "glossary_path": req.GlossaryPath, "output_path": req.OutputProcessedPath, "credential_names": names, }) } func discoverReports(outputPath string) (ReportBundle, error) { var reports ReportBundle for _, report := range []struct { name string dest *string }{ {"errors.yml", &reports.ErrorsPath}, {"recoveries.yml", &reports.RecoveriesPath}, {"report.md", &reports.OverviewPath}, } { path := filepath.Join(filepath.Dir(outputPath), report.name) info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return reports, fmt.Errorf("inspect audita report %q: %w", path, err) } if !info.Mode().IsRegular() { return reports, fmt.Errorf("audita report %q is not a regular file", path) } if _, err := fileops.ReadFileBounded(path, MaxReportOutputBytes); err != nil { return reports, fmt.Errorf("audita report %q exceeds or cannot be read within %d-byte limit: %w", path, MaxReportOutputBytes, err) } *report.dest = path } return reports, nil } func validateProcessedOutput(path string) error { data, err := fileops.ReadFileBounded(path, MaxProcessedOutputBytes) if err != nil { return fmt.Errorf("audita processed transcript result exceeds or cannot be read within %d-byte limit: %w", MaxProcessedOutputBytes, err) } var payload map[string]any if err := json.Unmarshal(data, &payload); err != nil { return fmt.Errorf("parse json: %w", err) } segments, ok := payload["segments"] if !ok { return fmt.Errorf("top-level segments is required") } if _, ok := segments.([]any); !ok { return fmt.Errorf("top-level segments must be an array") } return nil }