From 3becf3d0e34e0a88c3e7ba46b89b54bc438448bd Mon Sep 17 00:00:00 2001 From: Steven Mertens Date: Mon, 6 Apr 2026 00:02:34 +0200 Subject: [PATCH] feat: add versioned structured client output Introduce --format-version for client structured output. Version 1 preserves the legacy output shapes and behavior for single-target and batch runs. Version 2 adds a stable schema for structured output: - json/yaml use a top-level envelope with status, summary, and results - csv/csv-full include batch failures inline with status, tls_status, and error columns - status represents query execution status - tls_status represents TLS health for successful results This keeps backward compatibility for existing consumers while providing a clearer, versioned format for automation. --- README.md | 61 ++++- cmd/client.go | 75 +++++- cmd/client_targets_test.go | 34 +++ cmd/render_test.go | 131 +++++++++ internal/config/config.go | 17 ++ internal/config/config_test.go | 22 ++ internal/output/batch.go | 174 ++++++++++++ internal/output/csv.go | 167 ++++++++++-- internal/output/json.go | 9 + internal/output/renderer.go | 8 + internal/output/renderer_test.go | 443 +++++++++++++++++++++++++++++++ internal/output/yaml.go | 9 + 12 files changed, 1119 insertions(+), 31 deletions(-) create mode 100644 internal/output/batch.go diff --git a/README.md b/README.md index 45eaf36..f6ada00 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,46 @@ $ tlsctl client -o json badssl.com } ``` +With the default `--format-version 1`, single-target and multi-target output both preserve the legacy schema. For multiple targets, structured stdout contains only successful results and per-target runtime failures are still reported on stderr. + +Use `--format-version 2` if you want the same envelope for both single-target and multi-target client output: + +```bash +$ tlsctl client -o json --format-version 2 github.com missing.example.com +``` + +```json +{ + "status": "partial_success", + "summary": { + "total": 2, + "succeeded": 1, + "failed": 1 + }, + "results": [ + { + "target": "github.com:443", + "status": "success", + "tls_status": "secure", + "result": { + "certificates": [ + { + "type": "leaf", + "common_name": "github.com" + } + ], + "verified": true + } + }, + { + "target": "missing.example.com:443", + "status": "failure", + "error": "connection failed: dial tcp: lookup missing.example.com: no such host" + } + ] +} +``` + When `--tls-versions` is enabled, each `tls_versions` entry includes: ```json @@ -394,9 +434,13 @@ certificates: verified: true ``` +With the default `--format-version 1`, YAML also preserves the legacy behavior for both single-target and multi-target runs. Use `--format-version 2` if you want the stable `status` / `summary` / `results` envelope for both single-target and multi-target client output. + +In version 2 output, `status` describes whether the query itself succeeded, while `tls_status` describes the TLS health of successful results (`secure`, `expiring`, `insecure`, or `revocation_error`). + ### CSV output -Use `-o csv` for a concise, spreadsheet-friendly summary. Each input produces one row based on the leaf certificate: +Use `-o csv` for a concise, spreadsheet-friendly summary. Single-target output produces one row based on the leaf certificate: ```bash $ tlsctl client -o csv badssl.com @@ -407,6 +451,14 @@ target,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alt badssl.com:443,*.badssl.com,"CN=R13,O=Let's Encrypt,C=US",2026-01-20T20:02:51Z,2026-04-20T20:02:50Z,90,b4:5a:53:24:32:d9:8f:62:b6:ea:f1:47:32:06:10:f1:...,"*.badssl.com; badssl.com" ``` +Use `--format-version 2` with `csv` or `csv-full` if you want failed targets included inline with `status` and `error` columns: + +```csv +target,status,tls_status,error,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alternative_names +github.com:443,success,secure,,github.com,"CN=Sectigo Public Server Authentication CA DV E36,O=Sectigo Limited,C=GB",2026-03-06T00:00:00Z,2026-06-03T23:59:59Z,89,ab:cd:ef:...,"github.com; www.github.com" +missing.example.com:443,failure,,connection failed: dial tcp: lookup missing.example.com: no such host,,,,,,, +``` + Use `-o csv-full` if you want the row-per-certificate export with the wider field set. ### Raw PEM output @@ -537,8 +589,8 @@ tlsctl client -o json example.com | jq -r '.certificates[] | "\(.type): \(.seria # Count the number of SANs on the leaf certificate tlsctl client -o json example.com | jq '.certificates[] | select(.type == "leaf") | .subject_alternative_names | length' -# Check multiple hosts and report their expiry dates -tlsctl client -o json google.com github.com | jq -r '.[] | .certificates[] | select(.type == "leaf") | "\(.common_name) expires \(.not_after)"' +# Check multiple hosts and report their expiry dates with the stable v2 envelope +tlsctl client -o json --format-version 2 google.com github.com | jq -r '.results[] | select(.status == "success") | .result.certificates[] | select(.type == "leaf") | "\(.common_name) expires \(.not_after)"' ``` ## Output formats @@ -633,6 +685,7 @@ If the default configuration file is missing, `tlsctl` runs with built-in defaul "client": { "quiet": true, "expiry-warning": 21, + "format-version": 2, "proxy": "http://proxy:8080", "tls-versions": true, "revocation-soft-fail": false @@ -644,7 +697,7 @@ If the default configuration file is missing, `tlsctl` runs with built-in defaul } ``` -The `global` section applies to all subcommands and supports: `no-color`, `quiet`, `expiry-warning`, `output`, `cacert`, `revocation`, `revocation-timeout`, and `revocation-soft-fail`. Each subcommand section (`client`, `pem`) can override any global value with subcommand-specific settings. Only set the values you want to override — omitted keys inherit from `global` or use built-in defaults. +The `global` section applies to all subcommands and supports: `no-color`, `quiet`, `expiry-warning`, `output`, `cacert`, `revocation`, `revocation-timeout`, and `revocation-soft-fail`. The `client` section also supports `format-version` for versioned `json`, `yaml`, `csv`, and `csv-full` output. Each subcommand section (`client`, `pem`) can override any global value with subcommand-specific settings. Only set the values you want to override — omitted keys inherit from `global` or use built-in defaults. Invalid JSON, unknown keys, and invalid values (e.g., out-of-range `expiry-warning`) produce clear error messages. diff --git a/cmd/client.go b/cmd/client.go index a79c0d9..830fac7 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -45,6 +45,7 @@ func validateRevocationMode(mode string) error { func newClientCmd(rt *Runtime) *cobra.Command { var outputFormat string + var formatVersion int var caCertFile string var proxyURL string var inputFile string @@ -73,6 +74,9 @@ func newClientCmd(rt *Runtime) *cobra.Command { if err := validateRevocationMode(rf.mode); err != nil { return err } + if err := validateOutputFormatVersion(output.Format(outputFormat), formatVersion); err != nil { + return err + } if startTLS != "" && !tlsquery.ValidStartTLSProtocol(startTLS) { return fmt.Errorf("invalid --starttls protocol %q: must be one of %s", startTLS, tlsquery.StartTLSProtocolList()) @@ -95,6 +99,7 @@ func newClientCmd(rt *Runtime) *cobra.Command { renderOpts := output.Options{ Now: func() time.Time { return now }, ExpiryWarningDays: expiryWarningDays, + FormatVersion: formatVersion, } var revocationFn func(*tlsquery.ChainInfo) @@ -105,7 +110,6 @@ func newClientCmd(rt *Runtime) *cobra.Command { } results := queryTargets(targets, opts, revocationFn) - var chains []*tlsquery.ChainInfo var runtimeErrors []error for _, result := range results { if result.err != nil { @@ -113,18 +117,22 @@ func newClientCmd(rt *Runtime) *cobra.Command { continue } updateExitCodeForChain(rt.ExitTracker, result.chain, now, expiryWarningDays) - chains = append(chains, result.chain) } + renderedRuntimeErrors := false if !quiet { - if err := renderChains(rt.Stdout, output.Format(outputFormat), chains, renderOpts); err != nil { + var err error + renderedRuntimeErrors, err = renderTargetResults(rt.Stdout, output.Format(outputFormat), results, renderOpts) + if err != nil { return err } } if len(runtimeErrors) > 0 { - for _, err := range runtimeErrors { - fmt.Fprintln(rt.Stderr, err) + if quiet || !renderedRuntimeErrors { + for _, err := range runtimeErrors { + fmt.Fprintln(rt.Stderr, err) + } } rt.ExitTracker.Set(ExitRuntimeError) } @@ -133,6 +141,7 @@ func newClientCmd(rt *Runtime) *cobra.Command { } cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format: human (default), json, yaml, csv, csv-full, text (verbose), raw (PEM)") + cmd.Flags().IntVar(&formatVersion, "format-version", 1, "Structured output format version for client json, yaml, csv, or csv-full output") cmd.Flags().StringVar(&caCertFile, "cacert", "", "Path to CA certificate file (PEM format)") cmd.Flags().StringVarP(&proxyURL, "proxy", "x", "", "Proxy URL (e.g. http://proxy:8080). Falls back to HTTPS_PROXY/HTTP_PROXY env vars if not set") cmd.Flags().StringVar(&inputFile, "file", "", "Read endpoints from file (one per line, '-' for stdin)") @@ -145,6 +154,21 @@ func newClientCmd(rt *Runtime) *cobra.Command { return cmd } +func validateOutputFormatVersion(format output.Format, version int) error { + if version < 1 || version > 2 { + return fmt.Errorf("--format-version must be 1 or 2") + } + if version == 1 { + return nil + } + switch format { + case output.FormatJSON, output.FormatYAML, output.FormatCSV, output.FormatCSVFull: + return nil + default: + return fmt.Errorf("--format-version 2 is only supported with --output json, yaml, csv, or csv-full") + } +} + func runRevocationCheck(chain *tlsquery.ChainInfo, mode string, timeout time.Duration, softFail bool) { if len(chain.Certificates) == 0 { return @@ -344,6 +368,47 @@ func renderChains(w io.Writer, format output.Format, chains []*tlsquery.ChainInf return nil } +func renderTargetResults(w io.Writer, format output.Format, results []targetResult, opts output.Options) (bool, error) { + if len(results) == 0 { + return false, nil + } + + renderer, err := output.New(format) + if err != nil { + return false, err + } + + if opts.FormatVersionOrDefault() >= 2 { + if batchRenderer, ok := renderer.(output.BatchRenderer); ok { + return true, batchRenderer.RenderBatch(w, toOutputTargetResults(results), opts) + } + } + + var chains []*tlsquery.ChainInfo + for _, result := range results { + if result.err == nil { + chains = append(chains, result.chain) + } + } + + return false, renderChains(w, format, chains, opts) +} + +func toOutputTargetResults(results []targetResult) []output.TargetResult { + batch := make([]output.TargetResult, len(results)) + for i, result := range results { + batch[i] = output.TargetResult{ + Target: result.endpoint, + } + if result.err != nil { + batch[i].Error = result.err.Error() + continue + } + batch[i].Result = result.chain + } + return batch +} + func init() { rootCmd.AddCommand(newClientCmd(defaultRuntime)) } diff --git a/cmd/client_targets_test.go b/cmd/client_targets_test.go index 6abe9e1..a3d8a8a 100644 --- a/cmd/client_targets_test.go +++ b/cmd/client_targets_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "reflect" "testing" + + "github.com/catay/tlsctl/internal/output" ) func TestCollectTargets(t *testing.T) { @@ -80,3 +82,35 @@ example.org:8443 # inline comment }) } } + +func TestValidateOutputFormatVersion(t *testing.T) { + tests := []struct { + name string + format string + version int + wantErr bool + }{ + {name: "default version", format: "", version: 1}, + {name: "json v2", format: "json", version: 2}, + {name: "yaml v2", format: "yaml", version: 2}, + {name: "invalid version", format: "json", version: 3, wantErr: true}, + {name: "csv v2", format: "csv", version: 2}, + {name: "csv full v2", format: "csv-full", version: 2}, + {name: "human v2 unsupported", format: "", version: 2, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateOutputFormatVersion(output.Format(tt.format), tt.version) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/cmd/render_test.go b/cmd/render_test.go index 843712c..1f4615c 100644 --- a/cmd/render_test.go +++ b/cmd/render_test.go @@ -49,6 +49,26 @@ func testChains() []*tlsquery.ChainInfo { } } +func testTargetResults() []targetResult { + chains := testChains() + return []targetResult{ + { + index: 0, + endpoint: "a.example.com:443", + chain: chains[0], + }, + { + index: 1, + endpoint: "missing.example.com:443", + err: assertError("connection failed"), + }, + } +} + +type assertError string + +func (e assertError) Error() string { return string(e) } + func TestRenderChains_Empty(t *testing.T) { var buf bytes.Buffer err := renderChains(&buf, output.FormatJSON, nil, output.Options{}) @@ -196,3 +216,114 @@ func TestRenderChains_RawNoSeparator(t *testing.T) { t.Error("raw format should not have blank separators between PEM blocks") } } + +func TestRenderTargetResults_MultiJSONLegacy(t *testing.T) { + results := testTargetResults() + var buf bytes.Buffer + + renderedErrors, err := renderTargetResults(&buf, output.FormatJSON, results, output.Options{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if renderedErrors { + t.Fatal("expected format-version 1 JSON output to keep runtime errors out of structured stdout") + } + + var got tlsquery.ChainInfo + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("failed to unmarshal legacy JSON object: %v", err) + } + if len(got.Certificates) != 1 { + t.Fatalf("expected one successful chain in v1 JSON output, got %+v", got) + } + if got.Certificates[0].PEM != "" { + t.Error("PEM should be stripped from JSON output") + } +} + +func TestRenderTargetResults_MultiCSVLegacy(t *testing.T) { + results := testTargetResults() + var buf bytes.Buffer + + renderedErrors, err := renderTargetResults(&buf, output.FormatCSV, results, output.Options{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if renderedErrors { + t.Fatal("expected format-version 1 CSV output to keep runtime errors out of structured stdout") + } + + rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll() + if err != nil { + t.Fatalf("failed to parse legacy CSV output: %v", err) + } + if len(rows) != 2 { + t.Fatalf("expected header plus one successful row, got %d rows", len(rows)) + } + if rows[0][0] != "target" || rows[0][1] != "common_name" { + t.Fatalf("unexpected legacy CSV headers: %v", rows[0][:2]) + } + if rows[1][0] != "a.example.com:443" { + t.Fatalf("unexpected successful CSV row: %v", rows[1]) + } +} + +func TestRenderTargetResults_SingleJSONBatchV2(t *testing.T) { + results := testTargetResults()[:1] + var buf bytes.Buffer + + renderedErrors, err := renderTargetResults(&buf, output.FormatJSON, results, output.Options{FormatVersion: 2}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !renderedErrors { + t.Fatal("expected json v2 output to render through the batch envelope even for a single target") + } + + var envelope output.BatchEnvelope + if err := json.Unmarshal(buf.Bytes(), &envelope); err != nil { + t.Fatalf("failed to unmarshal batch envelope: %v", err) + } + if envelope.Status != output.StatusSuccess { + t.Fatalf("expected success envelope status, got %q", envelope.Status) + } + if envelope.Summary.Total != 1 || envelope.Summary.Succeeded != 1 || envelope.Summary.Failed != 0 { + t.Fatalf("unexpected batch summary: %+v", envelope.Summary) + } + if len(envelope.Results) != 1 || envelope.Results[0].Status != output.StatusSuccess { + t.Fatalf("unexpected batch results: %+v", envelope.Results) + } + if envelope.Results[0].TLSStatus != output.TLSStatusSecure { + t.Fatalf("expected secure tls_status, got %+v", envelope.Results[0]) + } +} + +func TestRenderTargetResults_MultiCSVBatchV2(t *testing.T) { + results := testTargetResults() + var buf bytes.Buffer + + renderedErrors, err := renderTargetResults(&buf, output.FormatCSV, results, output.Options{FormatVersion: 2}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !renderedErrors { + t.Fatal("expected format-version 2 CSV output to render runtime errors inline") + } + + rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll() + if err != nil { + t.Fatalf("failed to parse CSV v2 output: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected header plus two rows, got %d rows", len(rows)) + } + if rows[0][0] != "target" || rows[0][1] != "status" || rows[0][2] != "tls_status" || rows[0][3] != "error" { + t.Fatalf("unexpected CSV v2 headers: %v", rows[0][:4]) + } + if rows[1][0] != "a.example.com:443" || rows[1][1] != "success" || rows[1][2] != "secure" || rows[1][3] != "" { + t.Fatalf("unexpected success row: %v", rows[1][:4]) + } + if rows[2][0] != "missing.example.com:443" || rows[2][1] != "failure" || rows[2][2] != "" || rows[2][3] != "connection failed" { + t.Fatalf("unexpected failed row: %v", rows[2][:4]) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 4e521bb..24e6abb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,6 +54,7 @@ type ClientSettings struct { Quiet *bool `json:"quiet,omitempty"` ExpiryWarning *int `json:"expiry-warning,omitempty"` Output *string `json:"output,omitempty"` + FormatVersion *int `json:"format-version,omitempty"` CACert *string `json:"cacert,omitempty"` Proxy *string `json:"proxy,omitempty"` File *string `json:"file,omitempty"` @@ -129,6 +130,9 @@ func (s *Settings) validate() error { if err := validateExpiryWarning(s.Client.ExpiryWarning); err != nil { return fmt.Errorf("client.expiry-warning: %w", err) } + if err := validateFormatVersion(s.Client.FormatVersion); err != nil { + return fmt.Errorf("client.format-version: %w", err) + } if err := validateExpiryWarning(s.Pem.ExpiryWarning); err != nil { return fmt.Errorf("pem.expiry-warning: %w", err) } @@ -179,6 +183,16 @@ func validateStartTLS(v *string) error { return fmt.Errorf("must be one of %s", tlsquery.StartTLSProtocolList()) } +func validateFormatVersion(v *int) error { + if v == nil { + return nil + } + if *v < 1 || *v > 2 { + return fmt.Errorf("must be 1 or 2") + } + return nil +} + // FlagValues returns a map of flag-name to string-value for the given // subcommand section. Only non-nil fields are included. func (s *Settings) FlagValues(subcommand string) map[string]string { @@ -234,6 +248,9 @@ func addClientFlags(vals map[string]string, c *ClientSettings) { if c.Output != nil { vals["output"] = *c.Output } + if c.FormatVersion != nil { + vals["format-version"] = fmt.Sprintf("%d", *c.FormatVersion) + } if c.CACert != nil { vals["cacert"] = *c.CACert } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dd7d773..5cc09ab 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -115,6 +115,19 @@ func TestLoad_InvalidStartTLS(t *testing.T) { } } +func TestLoad_InvalidFormatVersion(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + if err := os.WriteFile(path, []byte(`{"client": {"format-version": 3}}`), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + _, err := Load(path, false) + if err == nil { + t.Fatal("expected error for invalid format-version") + } +} + func TestLoad_ValidConfig(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "settings.json") @@ -123,6 +136,7 @@ func TestLoad_ValidConfig(t *testing.T) { "client": { "expiry-warning": 21, "output": "json", + "format-version": 2, "proxy": "http://proxy:8080", "tls-versions": true, "revocation": "ocsp", @@ -165,6 +179,9 @@ func TestLoad_ValidConfig(t *testing.T) { if s.Client.Output == nil || *s.Client.Output != "json" { t.Error("expected client.output = json") } + if s.Client.FormatVersion == nil || *s.Client.FormatVersion != 2 { + t.Error("expected client.format-version = 2") + } if s.Client.Proxy == nil || *s.Client.Proxy != "http://proxy:8080" { t.Error("expected client.proxy") } @@ -190,10 +207,12 @@ func TestLoad_ValidConfig(t *testing.T) { func TestFlagValues_Client(t *testing.T) { expiry := 21 output := "json" + formatVersion := 2 s := &Settings{ Client: ClientSettings{ ExpiryWarning: &expiry, Output: &output, + FormatVersion: &formatVersion, }, } @@ -204,6 +223,9 @@ func TestFlagValues_Client(t *testing.T) { if vals["output"] != "json" { t.Errorf("expected output=json, got %s", vals["output"]) } + if vals["format-version"] != "2" { + t.Errorf("expected format-version=2, got %s", vals["format-version"]) + } } func TestFlagValues_Pem(t *testing.T) { diff --git a/internal/output/batch.go b/internal/output/batch.go new file mode 100644 index 0000000..5046294 --- /dev/null +++ b/internal/output/batch.go @@ -0,0 +1,174 @@ +package output + +import ( + "io" + + "github.com/catay/tlsctl/internal/revocation" + "github.com/catay/tlsctl/internal/tlsquery" +) + +type ResultStatus string +type TLSStatus string + +const ( + StatusSuccess ResultStatus = "success" + StatusFailure ResultStatus = "failure" + StatusPartialSuccess ResultStatus = "partial_success" + + TLSStatusSecure TLSStatus = "secure" + TLSStatusInsecure TLSStatus = "insecure" + TLSStatusExpiring TLSStatus = "expiring" + TLSStatusRevocationError TLSStatus = "revocation_error" +) + +type TargetResult struct { + Target string + Error string + Result *tlsquery.ChainInfo +} + +type Summary struct { + Total int `json:"total" yaml:"total"` + Succeeded int `json:"succeeded" yaml:"succeeded"` + Failed int `json:"failed" yaml:"failed"` +} + +type BatchEnvelope struct { + Status ResultStatus `json:"status" yaml:"status"` + Summary Summary `json:"summary" yaml:"summary"` + Results []BatchResultV2 `json:"results" yaml:"results"` +} + +type BatchResultV2 struct { + Target string `json:"target" yaml:"target"` + Status ResultStatus `json:"status" yaml:"status"` + TLSStatus TLSStatus `json:"tls_status,omitempty" yaml:"tls_status,omitempty"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` + Result *tlsquery.ChainInfo `json:"result,omitempty" yaml:"result,omitempty"` +} + +type batchResultV1 struct { + Target string `json:"target" yaml:"target"` + OK bool `json:"ok" yaml:"ok"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` + Result *tlsquery.ChainInfo `json:"result,omitempty" yaml:"result,omitempty"` +} + +// BatchRenderer renders per-target results, including failed targets. +type BatchRenderer interface { + RenderBatch(w io.Writer, results []TargetResult, opts Options) error +} + +func (r TargetResult) Status() ResultStatus { + if r.Error != "" { + return StatusFailure + } + return StatusSuccess +} + +func (r TargetResult) TLSStatus(opts Options) TLSStatus { + if r.Error != "" || r.Result == nil { + return "" + } + leaf, err := r.Result.Leaf() + if err != nil { + return "" + } + + if leaf.Revocation != nil && leaf.Revocation.OverallStatus == revocation.StatusError { + return TLSStatusRevocationError + } + if leaf.Revocation != nil && leaf.Revocation.OverallStatus == revocation.StatusRevoked { + return TLSStatusInsecure + } + if !r.Result.Verified { + return TLSStatusInsecure + } + + notAfter, err := leaf.NotAfterTime() + if err != nil { + return "" + } + if opts.NowFunc().After(notAfter) { + return TLSStatusInsecure + } + daysUntilExpiry := int(notAfter.Sub(opts.NowFunc()).Hours() / 24) + if daysUntilExpiry <= opts.WarningDays() { + return TLSStatusExpiring + } + return TLSStatusSecure +} + +func (r TargetResult) OK() bool { + return r.Error == "" +} + +func (r TargetResult) WithoutPEM() TargetResult { + out := r + if r.Result != nil { + out.Result = r.Result.WithoutPEM() + } + return out +} + +func cleanTargetResults(results []TargetResult) []TargetResult { + clean := make([]TargetResult, len(results)) + for i, result := range results { + clean[i] = result.WithoutPEM() + } + return clean +} + +func toBatchResultsV1(results []TargetResult) []batchResultV1 { + clean := cleanTargetResults(results) + out := make([]batchResultV1, len(clean)) + for i, result := range clean { + out[i] = batchResultV1{ + Target: result.Target, + OK: result.OK(), + Error: result.Error, + Result: result.Result, + } + } + return out +} + +func toBatchEnvelopeV2(results []TargetResult, opts Options) BatchEnvelope { + clean := cleanTargetResults(results) + out := BatchEnvelope{ + Summary: Summary{ + Total: len(clean), + }, + Results: make([]BatchResultV2, len(clean)), + } + + for i, result := range clean { + status := result.Status() + if status == StatusSuccess { + out.Summary.Succeeded++ + } else { + out.Summary.Failed++ + } + + out.Results[i] = BatchResultV2{ + Target: result.Target, + Status: status, + TLSStatus: result.TLSStatus(opts), + Error: result.Error, + Result: result.Result, + } + } + + switch { + case out.Summary.Total == 0: + out.Status = StatusSuccess + case out.Summary.Failed == 0: + out.Status = StatusSuccess + case out.Summary.Succeeded == 0: + out.Status = StatusFailure + default: + out.Status = StatusPartialSuccess + } + + return out +} diff --git a/internal/output/csv.go b/internal/output/csv.go index bfe5fa1..4a7cd3c 100644 --- a/internal/output/csv.go +++ b/internal/output/csv.go @@ -22,6 +22,10 @@ func (CSVRenderer) RenderAll(w io.Writer, chains []*tlsquery.ChainInfo, opts Opt return renderCSVSummary(w, chains, opts) } +func (CSVRenderer) RenderBatch(w io.Writer, results []TargetResult, opts Options) error { + return renderCSVSummaryBatch(w, results, opts) +} + func (CSVFullRenderer) Render(w io.Writer, chain *tlsquery.ChainInfo, opts Options) error { return renderCSVFull(w, []*tlsquery.ChainInfo{chain}) } @@ -30,6 +34,10 @@ func (CSVFullRenderer) RenderAll(w io.Writer, chains []*tlsquery.ChainInfo, opts return renderCSVFull(w, chains) } +func (CSVFullRenderer) RenderBatch(w io.Writer, results []TargetResult, opts Options) error { + return renderCSVFullBatch(w, results, opts) +} + func renderCSVSummary(w io.Writer, chains []*tlsquery.ChainInfo, opts Options) error { headers := []string{ csvInputHeader(chains), @@ -68,6 +76,28 @@ func renderCSVSummary(w io.Writer, chains []*tlsquery.ChainInfo, opts Options) e return writer.Error() } +func renderCSVSummaryBatch(w io.Writer, results []TargetResult, opts Options) error { + headers := csvSummaryBatchHeaders(opts) + + writer := csv.NewWriter(w) + if err := writer.Write(headers); err != nil { + return err + } + + for _, result := range results { + record, err := csvSummaryBatchRow(result, opts) + if err != nil { + return err + } + if err := writer.Write(record); err != nil { + return err + } + } + + writer.Flush() + return writer.Error() +} + func csvSummaryRow(chain *tlsquery.ChainInfo, leaf *tlsquery.CertInfo, opts Options) ([]string, error) { notAfter, err := leaf.NotAfterTime() if err != nil { @@ -88,9 +118,118 @@ func csvSummaryRow(chain *tlsquery.ChainInfo, leaf *tlsquery.CertInfo, opts Opti }, nil } +func csvSummaryBatchRow(result TargetResult, opts Options) ([]string, error) { + record := csvSummaryBatchPrefix(result, opts) + + if result.Result == nil { + return append(record, "", "", "", "", "", "", ""), nil + } + + leaf, err := result.Result.Leaf() + if err != nil { + return nil, err + } + + summary, err := csvSummaryRow(result.Result, leaf, opts) + if err != nil { + return nil, err + } + + return append(record, summary[1:]...), nil +} + func renderCSVFull(w io.Writer, chains []*tlsquery.ChainInfo) error { - headers := []string{ - csvInputHeader(chains), + headers := csvFullHeaders(csvInputHeader(chains)) + + writer := csv.NewWriter(w) + if err := writer.Write(headers); err != nil { + return err + } + + for _, chain := range chains { + if chain == nil { + continue + } + for certIndex := range chain.Certificates { + if err := writer.Write(csvFullRow(certIndex, chain, &chain.Certificates[certIndex])); err != nil { + return err + } + } + } + + writer.Flush() + return writer.Error() +} + +func renderCSVFullBatch(w io.Writer, results []TargetResult, opts Options) error { + headers := append(csvBatchPrefixHeaders(opts), csvFullHeaders("target")[1:]...) + + writer := csv.NewWriter(w) + if err := writer.Write(headers); err != nil { + return err + } + + for _, result := range results { + if result.Result == nil { + record := csvSummaryBatchPrefix(result, opts) + record = append(record, make([]string, len(headers)-len(record))...) + if err := writer.Write(record); err != nil { + return err + } + continue + } + + for certIndex := range result.Result.Certificates { + record := csvSummaryBatchPrefix(result, opts) + record = append(record, csvFullFieldsRow(certIndex, result.Result, &result.Result.Certificates[certIndex])...) + if err := writer.Write(record); err != nil { + return err + } + } + } + + writer.Flush() + return writer.Error() +} + +func csvSummaryBatchHeaders(opts Options) []string { + return append(csvBatchPrefixHeaders(opts), + "common_name", + "issuer", + "not_before", + "not_after", + "days_remaining", + "sha256", + "subject_alternative_names", + ) +} + +func csvBatchPrefixHeaders(opts Options) []string { + if opts.FormatVersionOrDefault() >= 2 { + return []string{"target", "status", "tls_status", "error"} + } + return []string{"target", "ok", "error"} +} + +func csvSummaryBatchPrefix(result TargetResult, opts Options) []string { + if opts.FormatVersionOrDefault() >= 2 { + return []string{ + result.Target, + string(result.Status()), + string(result.TLSStatus(opts)), + result.Error, + } + } + return []string{ + result.Target, + strconv.FormatBool(result.OK()), + result.Error, + } +} + +func csvFullHeaders(inputHeader string) []string { + return []string{ + inputHeader, "certificate_index", "certificate_type", "chain", @@ -126,28 +265,13 @@ func renderCSVFull(w io.Writer, chains []*tlsquery.ChainInfo) error { "revocation_status", "revocation_checked_at", } - - writer := csv.NewWriter(w) - if err := writer.Write(headers); err != nil { - return err - } - - for _, chain := range chains { - if chain == nil { - continue - } - for certIndex := range chain.Certificates { - if err := writer.Write(csvFullRow(certIndex, chain, &chain.Certificates[certIndex])); err != nil { - return err - } - } - } - - writer.Flush() - return writer.Error() } func csvFullRow(certIndex int, chain *tlsquery.ChainInfo, cert *tlsquery.CertInfo) []string { + return append([]string{csvInputValue(chain)}, csvFullFieldsRow(certIndex, chain, cert)...) +} + +func csvFullFieldsRow(certIndex int, chain *tlsquery.ChainInfo, cert *tlsquery.CertInfo) []string { basicConstraintsIsCA := "" basicConstraintsMaxPathLen := "" if cert.BasicConstraints != nil { @@ -163,7 +287,6 @@ func csvFullRow(certIndex int, chain *tlsquery.ChainInfo, cert *tlsquery.CertInf } return []string{ - csvInputValue(chain), strconv.Itoa(certIndex), cert.Type, strings.Join(chain.ChainNames(), " -> "), diff --git a/internal/output/json.go b/internal/output/json.go index 0c1ed49..36e6812 100644 --- a/internal/output/json.go +++ b/internal/output/json.go @@ -25,3 +25,12 @@ func (JSONRenderer) RenderAll(w io.Writer, chains []*tlsquery.ChainInfo, opts Op encoder.SetIndent("", " ") return encoder.Encode(clean) } + +func (JSONRenderer) RenderBatch(w io.Writer, results []TargetResult, opts Options) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + if opts.FormatVersionOrDefault() >= 2 { + return encoder.Encode(toBatchEnvelopeV2(results, opts)) + } + return encoder.Encode(toBatchResultsV1(results)) +} diff --git a/internal/output/renderer.go b/internal/output/renderer.go index e552f82..e919425 100644 --- a/internal/output/renderer.go +++ b/internal/output/renderer.go @@ -18,6 +18,7 @@ type MultiRenderer interface { type Options struct { Now func() time.Time ExpiryWarningDays int + FormatVersion int } func (o Options) NowFunc() time.Time { @@ -33,3 +34,10 @@ func (o Options) WarningDays() int { } return o.ExpiryWarningDays } + +func (o Options) FormatVersionOrDefault() int { + if o.FormatVersion <= 0 { + return 1 + } + return o.FormatVersion +} diff --git a/internal/output/renderer_test.go b/internal/output/renderer_test.go index 1e11656..c07274f 100644 --- a/internal/output/renderer_test.go +++ b/internal/output/renderer_test.go @@ -9,10 +9,18 @@ import ( "testing" "time" + "github.com/catay/tlsctl/internal/revocation" "github.com/catay/tlsctl/internal/tlsquery" "gopkg.in/yaml.v3" ) +type batchResultV1Doc struct { + Target string `json:"target" yaml:"target"` + OK bool `json:"ok" yaml:"ok"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` + Result *tlsquery.ChainInfo `json:"result,omitempty" yaml:"result,omitempty"` +} + func testChain() *tlsquery.ChainInfo { return &tlsquery.ChainInfo{ InputName: "test.example.com:443", @@ -180,6 +188,241 @@ func TestYAMLRenderer(t *testing.T) { } } +func TestJSONRendererBatch(t *testing.T) { + chain := testChain() + chain.Verified = true + var buf bytes.Buffer + r := JSONRenderer{} + + results := []TargetResult{ + { + Target: "test.example.com:443", + Result: chain, + }, + { + Target: "missing.example.com:443", + Error: "connection failed", + }, + } + + if err := r.RenderBatch(&buf, results, Options{Now: fixedNow}); err != nil { + t.Fatalf("RenderBatch failed: %v", err) + } + + var got []batchResultV1Doc + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("failed to unmarshal batch JSON: %v", err) + } + + if len(got) != 2 { + t.Fatalf("expected 2 batch results, got %d", len(got)) + } + if !got[0].OK || got[0].Result == nil { + t.Fatalf("expected first batch result to contain a successful chain") + } + if got[0].Result.Certificates[0].PEM != "" { + t.Error("PEM should be stripped from batch JSON output") + } + if got[1].OK { + t.Error("expected second batch result to be marked as failed") + } + if got[1].Error != "connection failed" { + t.Errorf("unexpected batch error: %q", got[1].Error) + } + if got[1].Result != nil { + t.Error("expected failed batch result to omit the chain result") + } +} + +func TestYAMLRendererBatch(t *testing.T) { + chain := testChain() + chain.Verified = true + var buf bytes.Buffer + r := YAMLRenderer{} + + results := []TargetResult{ + { + Target: "test.example.com:443", + Result: chain, + }, + { + Target: "missing.example.com:443", + Error: "connection failed", + }, + } + + if err := r.RenderBatch(&buf, results, Options{Now: fixedNow}); err != nil { + t.Fatalf("RenderBatch failed: %v", err) + } + + var got []batchResultV1Doc + if err := yaml.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("failed to unmarshal batch YAML: %v", err) + } + + if len(got) != 2 { + t.Fatalf("expected 2 batch results, got %d", len(got)) + } + if !got[0].OK || got[0].Result == nil { + t.Fatalf("expected first batch result to contain a successful chain") + } + if got[0].Result.Certificates[0].PEM != "" { + t.Error("PEM should be stripped from batch YAML output") + } + if got[1].Error != "connection failed" { + t.Errorf("unexpected batch error: %q", got[1].Error) + } +} + +func TestJSONRendererBatchV2(t *testing.T) { + chain := testChain() + chain.Verified = true + var buf bytes.Buffer + r := JSONRenderer{} + + results := []TargetResult{ + { + Target: "test.example.com:443", + Result: chain, + }, + { + Target: "missing.example.com:443", + Error: "connection failed", + }, + } + + if err := r.RenderBatch(&buf, results, Options{Now: fixedNow, FormatVersion: 2}); err != nil { + t.Fatalf("RenderBatch failed: %v", err) + } + + var got BatchEnvelope + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("failed to unmarshal batch JSON v2: %v", err) + } + + if got.Status != StatusPartialSuccess { + t.Fatalf("expected partial_success envelope status, got %q", got.Status) + } + if got.Summary.Total != 2 || got.Summary.Succeeded != 1 || got.Summary.Failed != 1 { + t.Fatalf("unexpected batch summary: %+v", got.Summary) + } + if len(got.Results) != 2 { + t.Fatalf("expected 2 batch results, got %d", len(got.Results)) + } + if got.Results[0].Status != StatusSuccess || got.Results[0].Result == nil { + t.Fatalf("expected successful first batch result, got %+v", got.Results[0]) + } + if got.Results[0].TLSStatus != TLSStatusSecure { + t.Fatalf("expected secure tls_status for first result, got %q", got.Results[0].TLSStatus) + } + if got.Results[0].Result.Certificates[0].PEM != "" { + t.Error("PEM should be stripped from batch JSON v2 output") + } + if got.Results[1].Status != StatusFailure || got.Results[1].Error != "connection failed" { + t.Fatalf("unexpected failed batch result: %+v", got.Results[1]) + } + if got.Results[1].TLSStatus != "" { + t.Fatalf("expected empty tls_status for failed result, got %q", got.Results[1].TLSStatus) + } +} + +func TestYAMLRendererBatchV2(t *testing.T) { + chain := testChain() + chain.Verified = true + var buf bytes.Buffer + r := YAMLRenderer{} + + results := []TargetResult{ + { + Target: "test.example.com:443", + Result: chain, + }, + { + Target: "missing.example.com:443", + Error: "connection failed", + }, + } + + if err := r.RenderBatch(&buf, results, Options{Now: fixedNow, FormatVersion: 2}); err != nil { + t.Fatalf("RenderBatch failed: %v", err) + } + + var got BatchEnvelope + if err := yaml.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("failed to unmarshal batch YAML v2: %v", err) + } + + if got.Status != StatusPartialSuccess { + t.Fatalf("expected partial_success envelope status, got %q", got.Status) + } + if got.Summary.Total != 2 || got.Summary.Succeeded != 1 || got.Summary.Failed != 1 { + t.Fatalf("unexpected batch summary: %+v", got.Summary) + } + if got.Results[0].Status != StatusSuccess || got.Results[1].Status != StatusFailure { + t.Fatalf("unexpected per-result statuses: %+v", got.Results) + } + if got.Results[0].TLSStatus != TLSStatusSecure || got.Results[1].TLSStatus != "" { + t.Fatalf("unexpected per-result tls statuses: %+v", got.Results) + } +} + +func TestTargetResultTLSStatus(t *testing.T) { + tests := []struct { + name string + mutate func(*TargetResult) + want TLSStatus + }{ + { + name: "secure", + mutate: func(result *TargetResult) {}, + want: TLSStatusSecure, + }, + { + name: "expiring", + mutate: func(result *TargetResult) { + result.Result.Certificates[0].NotAfter = "2026-02-20T00:00:00Z" + }, + want: TLSStatusExpiring, + }, + { + name: "insecure verification", + mutate: func(result *TargetResult) { + result.Result.Verified = false + }, + want: TLSStatusInsecure, + }, + { + name: "revocation error", + mutate: func(result *TargetResult) { + result.Result.Certificates[0].Revocation = &revocation.Info{OverallStatus: revocation.StatusError} + }, + want: TLSStatusRevocationError, + }, + { + name: "query failure", + mutate: func(result *TargetResult) { + result.Error = "connection failed" + result.Result = nil + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TargetResult{ + Target: "test.example.com:443", + Result: testChain(), + } + result.Result.Verified = true + tt.mutate(&result) + if got := result.TLSStatus(Options{Now: fixedNow, FormatVersion: 2}); got != tt.want { + t.Fatalf("TLSStatus() = %q, want %q", got, tt.want) + } + }) + } +} + func TestCSVRenderer(t *testing.T) { chain := testChain() chain.Verified = true @@ -260,6 +503,206 @@ func TestCSVRenderer(t *testing.T) { } } +func TestCSVRendererBatch(t *testing.T) { + chain := testChain() + chain.Verified = true + var buf bytes.Buffer + r := CSVRenderer{} + + results := []TargetResult{ + { + Target: "test.example.com:443", + Result: chain, + }, + { + Target: "missing.example.com:443", + Error: "connection failed", + }, + } + + if err := r.RenderBatch(&buf, results, Options{Now: fixedNow}); err != nil { + t.Fatalf("RenderBatch failed: %v", err) + } + + rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll() + if err != nil { + t.Fatalf("failed to parse batch CSV: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected header plus two rows, got %d rows", len(rows)) + } + + record1 := make(map[string]string, len(rows[0])) + record2 := make(map[string]string, len(rows[0])) + for i, header := range rows[0] { + record1[header] = rows[1][i] + record2[header] = rows[2][i] + } + + if record1["ok"] != "true" || record1["error"] != "" { + t.Fatalf("expected successful batch row, got ok=%q error=%q", record1["ok"], record1["error"]) + } + if record1["common_name"] != "test.example.com" { + t.Errorf("unexpected common_name for success row: %q", record1["common_name"]) + } + if record2["target"] != "missing.example.com:443" { + t.Errorf("unexpected target for error row: %q", record2["target"]) + } + if record2["ok"] != "false" || record2["error"] != "connection failed" { + t.Fatalf("expected failed batch row, got ok=%q error=%q", record2["ok"], record2["error"]) + } + if record2["common_name"] != "" { + t.Errorf("expected empty common_name for failed row, got %q", record2["common_name"]) + } +} + +func TestCSVRendererBatchV2(t *testing.T) { + chain := testChain() + chain.Verified = true + var buf bytes.Buffer + r := CSVRenderer{} + + results := []TargetResult{ + { + Target: "test.example.com:443", + Result: chain, + }, + { + Target: "missing.example.com:443", + Error: "connection failed", + }, + } + + if err := r.RenderBatch(&buf, results, Options{Now: fixedNow, FormatVersion: 2}); err != nil { + t.Fatalf("RenderBatch failed: %v", err) + } + + rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll() + if err != nil { + t.Fatalf("failed to parse batch CSV v2: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected header plus two rows, got %d rows", len(rows)) + } + + record1 := make(map[string]string, len(rows[0])) + record2 := make(map[string]string, len(rows[0])) + for i, header := range rows[0] { + record1[header] = rows[1][i] + record2[header] = rows[2][i] + } + + if record1["status"] != "success" || record1["tls_status"] != "secure" || record1["error"] != "" { + t.Fatalf("expected successful batch row, got status=%q tls_status=%q error=%q", record1["status"], record1["tls_status"], record1["error"]) + } + if record1["common_name"] != "test.example.com" { + t.Errorf("unexpected common_name for success row: %q", record1["common_name"]) + } + if record2["target"] != "missing.example.com:443" { + t.Errorf("unexpected target for error row: %q", record2["target"]) + } + if record2["status"] != "failure" || record2["tls_status"] != "" || record2["error"] != "connection failed" { + t.Fatalf("expected failed batch row, got status=%q tls_status=%q error=%q", record2["status"], record2["tls_status"], record2["error"]) + } + if record2["common_name"] != "" { + t.Errorf("expected empty common_name for failed row, got %q", record2["common_name"]) + } +} + +func TestCSVFullRendererBatch(t *testing.T) { + chain := testChain() + chain.Verified = true + var buf bytes.Buffer + r := CSVFullRenderer{} + + results := []TargetResult{ + { + Target: "test.example.com:443", + Result: chain, + }, + { + Target: "missing.example.com:443", + Error: "connection failed", + }, + } + + if err := r.RenderBatch(&buf, results, Options{Now: fixedNow}); err != nil { + t.Fatalf("RenderBatch failed: %v", err) + } + + rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll() + if err != nil { + t.Fatalf("failed to parse batch CSV full output: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected header plus two rows, got %d rows", len(rows)) + } + + record1 := make(map[string]string, len(rows[0])) + record2 := make(map[string]string, len(rows[0])) + for i, header := range rows[0] { + record1[header] = rows[1][i] + record2[header] = rows[2][i] + } + + if record1["ok"] != "true" || record1["certificate_type"] != "leaf" { + t.Fatalf("unexpected success row in batch csv-full: ok=%q certificate_type=%q", record1["ok"], record1["certificate_type"]) + } + if record2["ok"] != "false" || record2["error"] != "connection failed" { + t.Fatalf("unexpected error row in batch csv-full: ok=%q error=%q", record2["ok"], record2["error"]) + } + if record2["certificate_type"] != "" { + t.Errorf("expected empty certificate_type for failed row, got %q", record2["certificate_type"]) + } +} + +func TestCSVFullRendererBatchV2(t *testing.T) { + chain := testChain() + chain.Verified = true + var buf bytes.Buffer + r := CSVFullRenderer{} + + results := []TargetResult{ + { + Target: "test.example.com:443", + Result: chain, + }, + { + Target: "missing.example.com:443", + Error: "connection failed", + }, + } + + if err := r.RenderBatch(&buf, results, Options{Now: fixedNow, FormatVersion: 2}); err != nil { + t.Fatalf("RenderBatch failed: %v", err) + } + + rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll() + if err != nil { + t.Fatalf("failed to parse batch CSV full v2 output: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected header plus two rows, got %d rows", len(rows)) + } + + record1 := make(map[string]string, len(rows[0])) + record2 := make(map[string]string, len(rows[0])) + for i, header := range rows[0] { + record1[header] = rows[1][i] + record2[header] = rows[2][i] + } + + if record1["status"] != "success" || record1["tls_status"] != "secure" || record1["certificate_type"] != "leaf" { + t.Fatalf("unexpected success row in batch csv-full v2: status=%q tls_status=%q certificate_type=%q", record1["status"], record1["tls_status"], record1["certificate_type"]) + } + if record2["status"] != "failure" || record2["tls_status"] != "" || record2["error"] != "connection failed" { + t.Fatalf("unexpected error row in batch csv-full v2: status=%q tls_status=%q error=%q", record2["status"], record2["tls_status"], record2["error"]) + } + if record2["certificate_type"] != "" { + t.Errorf("expected empty certificate_type for failed row, got %q", record2["certificate_type"]) + } +} + func TestRawPEMRenderer(t *testing.T) { chain := testChain() chain.Verified = true diff --git a/internal/output/yaml.go b/internal/output/yaml.go index 031e536..5eddc13 100644 --- a/internal/output/yaml.go +++ b/internal/output/yaml.go @@ -25,3 +25,12 @@ func (YAMLRenderer) RenderAll(w io.Writer, chains []*tlsquery.ChainInfo, opts Op encoder.SetIndent(2) return encoder.Encode(clean) } + +func (YAMLRenderer) RenderBatch(w io.Writer, results []TargetResult, opts Options) error { + encoder := yaml.NewEncoder(w) + encoder.SetIndent(2) + if opts.FormatVersionOrDefault() >= 2 { + return encoder.Encode(toBatchEnvelopeV2(results, opts)) + } + return encoder.Encode(toBatchResultsV1(results)) +}