Add Debian packaging, Makefile, manpages, tests, and design doc
Introduces a static-binary build and Debian package (amd64/arm64) with version/commit/date stamped via -ldflags. Ships section-1 manpages for ctool, ctfetch, and ctail. Adds a `version` subcommand reachable as `ctool version`, `ctool -version`, `ctool --version`, `ctool fetch version`, `ctool tail version`, and via the ctfetch/ctail symlinks. Adds tests covering the dispatcher, fetch/tail argument parsing, and the formatter/helper functions. Adds a retrofit design document modelled on the vpp-maglev one, with FRs and NFRs for each tool and the dispatcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,10 @@ import (
|
||||
)
|
||||
|
||||
func runFetch(args []string) {
|
||||
if len(args) > 0 && args[0] == "version" {
|
||||
printVersion()
|
||||
return
|
||||
}
|
||||
fs := flag.NewFlagSet("fetch", flag.ContinueOnError)
|
||||
logsListURL := fs.String("logs-list-url", "https://www.gstatic.com/ct/log_list/v3/all_logs_list.json", "URL of the CT log list JSON")
|
||||
monitoringURL := fs.String("monitoring-url", "", "log root URL for issuer lookups when input is a file")
|
||||
|
||||
73
cmd/ctool/cmd_fetch_test.go
Normal file
73
cmd/ctool/cmd_fetch_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchNoArgsShowsUsage(t *testing.T) {
|
||||
_, stderr, exit := runCLI(t, "ctool", "fetch")
|
||||
if exit == 0 {
|
||||
t.Errorf("expected non-zero exit, got 0 (stderr=%q)", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "Usage:") {
|
||||
t.Errorf("stderr missing usage header: %q", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "ctool fetch") {
|
||||
t.Errorf("stderr missing qualified command name: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// When invoked via the ctfetch symlink, the usage banner must use the
|
||||
// symlink name ("ctfetch"), not "ctool fetch". This exercises cmdName's
|
||||
// symlink branch through the real CLI.
|
||||
func TestFetchUsageUsesSymlinkName(t *testing.T) {
|
||||
_, stderr, exit := runCLI(t, "ctfetch")
|
||||
if exit == 0 {
|
||||
t.Errorf("expected non-zero exit, got 0")
|
||||
}
|
||||
if !strings.Contains(stderr, "ctfetch") {
|
||||
t.Errorf("stderr should mention ctfetch: %q", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "ctool fetch") {
|
||||
t.Errorf("stderr should NOT say 'ctool fetch' when invoked via symlink: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchHelpExitsZero(t *testing.T) {
|
||||
_, _, exit := runCLI(t, "ctool", "fetch", "-h")
|
||||
if exit != 0 {
|
||||
t.Errorf("expected exit 0 for -h, got %d", exit)
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown +modifier must be rejected before any network request is made.
|
||||
// The fetch dispatcher validates the modifier enum (sct/issuer/ctlog/all)
|
||||
// up-front; we pass a junk URL to prove the error happens during parse,
|
||||
// not during HTTP.
|
||||
func TestFetchRejectsUnknownModifier(t *testing.T) {
|
||||
_, stderr, exit := runCLI(t, "ctool", "fetch", "http://invalid.invalid", "1", "+bogus")
|
||||
if exit == 0 {
|
||||
t.Errorf("expected non-zero exit")
|
||||
}
|
||||
if !strings.Contains(stderr, "unknown argument") {
|
||||
t.Errorf("stderr should mention 'unknown argument', got %q", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "+bogus") {
|
||||
t.Errorf("stderr should echo the bad modifier: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// Leaf-index mode is distinguished from tile-dump mode by whether the
|
||||
// second positional parses as an integer. The +modifier-rejection path
|
||||
// runs through different code for each mode; verify both reach the
|
||||
// same validation.
|
||||
func TestFetchRejectsUnknownModifierTileMode(t *testing.T) {
|
||||
_, stderr, exit := runCLI(t, "ctool", "fetch", "http://invalid.invalid/tile/data/x000/000", "+bogus")
|
||||
if exit == 0 {
|
||||
t.Errorf("expected non-zero exit")
|
||||
}
|
||||
if !strings.Contains(stderr, "unknown argument") {
|
||||
t.Errorf("stderr should mention 'unknown argument', got %q", stderr)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ var (
|
||||
)
|
||||
|
||||
func runTail(args []string) {
|
||||
if len(args) > 0 && args[0] == "version" {
|
||||
printVersion()
|
||||
return
|
||||
}
|
||||
fs := flag.NewFlagSet("tail", flag.ContinueOnError)
|
||||
interval := fs.Duration("interval", 15*time.Second, "polling interval (minimum 1s)")
|
||||
fromLeaf := fs.Int64("from-leaf", -1, "start from this leaf index (-1 = current tree tip)")
|
||||
@@ -154,7 +158,7 @@ func fetchURL(url string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
164
cmd/ctool/cmd_tail_test.go
Normal file
164
cmd/ctool/cmd_tail_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/sunlight"
|
||||
)
|
||||
|
||||
func TestTailNoArgsShowsUsage(t *testing.T) {
|
||||
_, stderr, exit := runCLI(t, "ctool", "tail")
|
||||
if exit == 0 {
|
||||
t.Errorf("expected non-zero exit, got 0")
|
||||
}
|
||||
if !strings.Contains(stderr, "Usage:") {
|
||||
t.Errorf("stderr missing usage header: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailRejectsShortInterval(t *testing.T) {
|
||||
_, stderr, exit := runCLI(t, "ctool", "tail", "--interval", "500ms", "http://invalid.invalid")
|
||||
if exit == 0 {
|
||||
t.Errorf("expected non-zero exit")
|
||||
}
|
||||
if !strings.Contains(stderr, "interval must be at least 1s") {
|
||||
t.Errorf("stderr=%q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTailRejectsShortRateLimit(t *testing.T) {
|
||||
_, stderr, exit := runCLI(t, "ctool", "tail", "--rate-limit", "50ms", "http://invalid.invalid")
|
||||
if exit == 0 {
|
||||
t.Errorf("expected non-zero exit")
|
||||
}
|
||||
if !strings.Contains(stderr, "rate-limit must be at least 100ms") {
|
||||
t.Errorf("stderr=%q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrunc(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{"hello", 10, "hello"},
|
||||
{"hello", 5, "hello"},
|
||||
{"abcdefghij", 6, "abc..."},
|
||||
{"abcdefghij", 5, "ab..."},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := trunc(c.in, c.n); got != c.want {
|
||||
t.Errorf("trunc(%q,%d)=%q, want %q", c.in, c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuerLabel(t *testing.T) {
|
||||
terse := &x509.Certificate{Issuer: pkix.Name{
|
||||
CommonName: "R13",
|
||||
Organization: []string{"Let's Encrypt"},
|
||||
}}
|
||||
if got := issuerLabel(terse); got != "Let's Encrypt R13" {
|
||||
t.Errorf("terse CN: got %q", got)
|
||||
}
|
||||
|
||||
self := &x509.Certificate{Issuer: pkix.Name{
|
||||
CommonName: "Let's Encrypt Authority X3",
|
||||
Organization: []string{"Let's Encrypt"},
|
||||
}}
|
||||
if got := issuerLabel(self); got != "Let's Encrypt Authority X3" {
|
||||
t.Errorf("CN already mentions org: got %q", got)
|
||||
}
|
||||
|
||||
noOrg := &x509.Certificate{Issuer: pkix.Name{CommonName: "ipng root"}}
|
||||
if got := issuerLabel(noOrg); got != "ipng root" {
|
||||
t.Errorf("no org: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// makeDERCert mints a short-lived, self-signed ECDSA cert for tests. Using
|
||||
// P-256 keeps generation to single-digit milliseconds so the test stays fast.
|
||||
func makeDERCert(t *testing.T, cn, org, dnsName string, notBefore, notAfter time.Time) []byte {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("genkey: %v", err)
|
||||
}
|
||||
// In a self-signed cert (parent == template), the issuer of the
|
||||
// resulting cert is taken from the template's Subject, not its Issuer.
|
||||
// So we put the issuer identity in Subject and let x509 mirror it.
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: cn,
|
||||
Organization: []string{org},
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
DNSNames: []string{dnsName},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCertificate: %v", err)
|
||||
}
|
||||
return der
|
||||
}
|
||||
|
||||
func TestFormatEntry(t *testing.T) {
|
||||
notBefore := time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC)
|
||||
notAfter := time.Date(2026, 6, 29, 0, 0, 0, 0, time.UTC)
|
||||
der := makeDERCert(t, "R13", "Let's Encrypt", "example.com", notBefore, notAfter)
|
||||
|
||||
e := &sunlight.LogEntry{
|
||||
LeafIndex: 1234567,
|
||||
IsPrecert: false,
|
||||
Certificate: der,
|
||||
}
|
||||
line := formatEntry(e)
|
||||
for _, want := range []string{"1234567", "cert", "2026-03-31..2026-06-29", "Let's Encrypt R13", "example.com"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Errorf("formatEntry output missing %q: %q", want, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatEntryPrecert(t *testing.T) {
|
||||
notBefore := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
notAfter := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||
der := makeDERCert(t, "IPng CA", "IPng Networks", "pre.example.com", notBefore, notAfter)
|
||||
|
||||
e := &sunlight.LogEntry{
|
||||
LeafIndex: 42,
|
||||
IsPrecert: true,
|
||||
PreCertificate: der,
|
||||
}
|
||||
line := formatEntry(e)
|
||||
if !strings.Contains(line, "pre ") {
|
||||
t.Errorf("expected 'pre ' marker for precert: %q", line)
|
||||
}
|
||||
if !strings.Contains(line, "pre.example.com") {
|
||||
t.Errorf("expected precert DNS name: %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// An entry with no certificate bytes must still render safely, with the
|
||||
// documented "(unknown)" placeholder rather than panicking or skipping.
|
||||
func TestFormatEntryUnknownCert(t *testing.T) {
|
||||
e := &sunlight.LogEntry{LeafIndex: 7}
|
||||
line := formatEntry(e)
|
||||
if !strings.Contains(line, "(unknown)") {
|
||||
t.Errorf("expected '(unknown)' placeholder, got %q", line)
|
||||
}
|
||||
if !strings.Contains(line, "cert") {
|
||||
t.Errorf("expected type 'cert': %q", line)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,16 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const version = "0.1.0"
|
||||
// version, commit, and date are stamped at link time via -ldflags "-X ...":
|
||||
//
|
||||
// -X main.version=X.Y.Z -X main.commit=<sha> -X main.date=<iso8601>
|
||||
//
|
||||
// Defaults keep `go run` / `go build` without ldflags usable.
|
||||
var (
|
||||
version = "0.1.0"
|
||||
commit = "unknown"
|
||||
date = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Busybox-style: if invoked as "ctfetch" or "ctail", shortcut to that command.
|
||||
@@ -35,6 +44,8 @@ func main() {
|
||||
runFetch(os.Args[2:])
|
||||
case "tail":
|
||||
runTail(os.Args[2:])
|
||||
case "version", "-version", "--version":
|
||||
printVersion()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Error: unknown command %q\n\n", os.Args[1])
|
||||
usage()
|
||||
@@ -42,11 +53,20 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// printVersion writes the build version, commit hash, and build date to
|
||||
// stdout, using the binary's invocation name so the output reads naturally
|
||||
// whether called as `ctool version`, `ctfetch version`, or `ctail version`.
|
||||
func printVersion() {
|
||||
base := strings.TrimSuffix(filepath.Base(os.Args[0]), filepath.Ext(os.Args[0]))
|
||||
fmt.Printf("%s version %s (commit %s, built %s)\n", base, version, commit, date)
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, "Usage: ctool <command> [flags] ...\n\n")
|
||||
fmt.Fprintf(os.Stderr, "Commands:\n")
|
||||
fmt.Fprintf(os.Stderr, " fetch fetch and decode CT log entries as JSON\n")
|
||||
fmt.Fprintf(os.Stderr, " tail tail a CT log, printing one-liners for new entries\n")
|
||||
fmt.Fprintf(os.Stderr, " fetch fetch and decode CT log entries as JSON\n")
|
||||
fmt.Fprintf(os.Stderr, " tail tail a CT log, printing one-liners for new entries\n")
|
||||
fmt.Fprintf(os.Stderr, " version print version, commit hash, and build date\n")
|
||||
fmt.Fprintf(os.Stderr, "\nRun 'ctool <command> --help' for command-specific flags.\n")
|
||||
}
|
||||
|
||||
|
||||
163
cmd/ctool/main_test.go
Normal file
163
cmd/ctool/main_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain lets a subprocess re-enter main() so we can drive the real CLI
|
||||
// dispatch from inside the test binary. When CTOOL_TEST_MAIN=1 is set,
|
||||
// the test binary replaces os.Args from CTOOL_TEST_ARGV0/CTOOL_TEST_ARGS
|
||||
// and calls main() directly; m.Run() is skipped in that child.
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv("CTOOL_TEST_MAIN") == "1" {
|
||||
argv := []string{os.Getenv("CTOOL_TEST_ARGV0")}
|
||||
if rest := os.Getenv("CTOOL_TEST_ARGS"); rest != "" {
|
||||
argv = append(argv, strings.Split(rest, "\t")...)
|
||||
}
|
||||
os.Args = argv
|
||||
main()
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// runCLI re-execs the test binary as the ctool CLI with the given argv[0]
|
||||
// and argv[1:], capturing stdout, stderr, and the exit code.
|
||||
func runCLI(t *testing.T, argv0 string, args ...string) (stdout, stderr string, exit int) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^$")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"CTOOL_TEST_MAIN=1",
|
||||
"CTOOL_TEST_ARGV0="+argv0,
|
||||
"CTOOL_TEST_ARGS="+strings.Join(args, "\t"),
|
||||
)
|
||||
var o, e bytes.Buffer
|
||||
cmd.Stdout = &o
|
||||
cmd.Stderr = &e
|
||||
err := cmd.Run()
|
||||
switch v := err.(type) {
|
||||
case nil:
|
||||
exit = 0
|
||||
case *exec.ExitError:
|
||||
exit = v.ExitCode()
|
||||
default:
|
||||
t.Fatalf("runCLI: exec failed: %v", err)
|
||||
}
|
||||
return o.String(), e.String(), exit
|
||||
}
|
||||
|
||||
func TestCmdName(t *testing.T) {
|
||||
save := os.Args
|
||||
t.Cleanup(func() { os.Args = save })
|
||||
|
||||
cases := []struct {
|
||||
argv0 string
|
||||
sub string
|
||||
want string
|
||||
}{
|
||||
{"/usr/bin/ctool", "fetch", "ctool fetch"},
|
||||
{"/usr/bin/ctool", "tail", "ctool tail"},
|
||||
{"ctool", "fetch", "ctool fetch"},
|
||||
{"ctool.exe", "fetch", "ctool fetch"},
|
||||
{"/usr/bin/ctfetch", "fetch", "ctfetch"},
|
||||
{"ctail", "tail", "ctail"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
os.Args = []string{c.argv0}
|
||||
if got := cmdName(c.sub); got != c.want {
|
||||
t.Errorf("argv0=%q sub=%q: cmdName()=%q, want %q", c.argv0, c.sub, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// capturePrintVersion captures os.Stdout while calling printVersion(). We
|
||||
// assert on the format rather than the exact version string so the test
|
||||
// stays stable whether or not -ldflags stamped a commit/date.
|
||||
func capturePrintVersion(t *testing.T, argv0 string) string {
|
||||
t.Helper()
|
||||
saveArgs, saveStdout := os.Args, os.Stdout
|
||||
t.Cleanup(func() { os.Args, os.Stdout = saveArgs, saveStdout })
|
||||
os.Args = []string{argv0}
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
os.Stdout = w
|
||||
printVersion()
|
||||
_ = w.Close()
|
||||
out, _ := io.ReadAll(r)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestPrintVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
argv0 string
|
||||
prefix string
|
||||
}{
|
||||
{"ctool", "ctool version "},
|
||||
{"ctfetch", "ctfetch version "},
|
||||
{"/usr/bin/ctail", "ctail version "},
|
||||
}
|
||||
for _, c := range cases {
|
||||
out := capturePrintVersion(t, c.argv0)
|
||||
if !strings.HasPrefix(out, c.prefix) {
|
||||
t.Errorf("argv0=%q: printVersion()=%q, want prefix %q", c.argv0, out, c.prefix)
|
||||
}
|
||||
if !strings.Contains(out, "commit ") || !strings.Contains(out, "built ") {
|
||||
t.Errorf("argv0=%q: printVersion()=%q, missing commit/built fields", c.argv0, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
argv0 string
|
||||
args []string
|
||||
prefix string
|
||||
}{
|
||||
{"ctool", []string{"version"}, "ctool version "},
|
||||
{"ctool", []string{"-version"}, "ctool version "},
|
||||
{"ctool", []string{"--version"}, "ctool version "},
|
||||
{"ctool", []string{"fetch", "version"}, "ctool version "},
|
||||
{"ctool", []string{"tail", "version"}, "ctool version "},
|
||||
{"ctfetch", []string{"version"}, "ctfetch version "},
|
||||
{"ctail", []string{"version"}, "ctail version "},
|
||||
}
|
||||
for _, c := range cases {
|
||||
stdout, _, exit := runCLI(t, c.argv0, c.args...)
|
||||
if exit != 0 {
|
||||
t.Errorf("argv=%v %v: exit %d, want 0", c.argv0, c.args, exit)
|
||||
}
|
||||
if !strings.HasPrefix(stdout, c.prefix) {
|
||||
t.Errorf("argv=%v %v: stdout=%q, want prefix %q", c.argv0, c.args, stdout, c.prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchNoArgsShowsUsage(t *testing.T) {
|
||||
_, stderr, exit := runCLI(t, "ctool")
|
||||
if exit == 0 {
|
||||
t.Errorf("expected non-zero exit, got 0 (stderr=%q)", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "Usage: ctool") {
|
||||
t.Errorf("stderr missing usage header: %q", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "fetch") || !strings.Contains(stderr, "tail") || !strings.Contains(stderr, "version") {
|
||||
t.Errorf("stderr missing subcommand list: %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchUnknownSubcommand(t *testing.T) {
|
||||
_, stderr, exit := runCLI(t, "ctool", "bogus")
|
||||
if exit == 0 {
|
||||
t.Errorf("expected non-zero exit, got 0")
|
||||
}
|
||||
if !strings.Contains(stderr, "unknown command") {
|
||||
t.Errorf("stderr=%q", stderr)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user