Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions cmd/touchstone/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,44 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"

touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1"
"github.com/FootprintAI/touchstone/internal/fetch"
"github.com/FootprintAI/touchstone/internal/service"
"google.golang.org/protobuf/encoding/protojson"
)

const usage = "usage: touchstone scan <url>"
const usage = "usage: touchstone scan [--allow-private] <url>"

// newService builds the ScanService for one CLI invocation. The fetch
// options carry CLI-only settings such as --allow-private, which the
// server never accepts.
type newService func(fetch.Options) touchstonev1.ScanServiceServer

func main() {
if err := run(context.Background(), os.Args[1:], os.Stdout, &service.Stub{}); err != nil {
newSvc := func(fetch.Options) touchstonev1.ScanServiceServer { return &service.Stub{} }
if err := run(context.Background(), os.Args[1:], os.Stdout, newSvc); err != nil {
fmt.Fprintln(os.Stderr, "touchstone:", err)
os.Exit(2)
}
}

func run(ctx context.Context, args []string, out io.Writer, svc touchstonev1.ScanServiceServer) error {
if len(args) != 2 || args[0] != "scan" {
func run(ctx context.Context, args []string, out io.Writer, newSvc newService) error {
if len(args) == 0 || args[0] != "scan" {
return errors.New(usage)
}
fs := flag.NewFlagSet("scan", flag.ContinueOnError)
fs.SetOutput(io.Discard)
allowPrivate := fs.Bool("allow-private", false, "allow fetching loopback and private addresses (local fixtures only)")
if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 1 {
return errors.New(usage)
}
report, err := svc.Scan(ctx, &touchstonev1.ScanRequest{Url: args[1]})
svc := newSvc(fetch.Options{AllowPrivate: *allowPrivate, Version: service.ToolVersion})
report, err := svc.Scan(ctx, &touchstonev1.ScanRequest{Url: fs.Arg(0)})
if err != nil {
return err
}
Expand Down
34 changes: 30 additions & 4 deletions cmd/touchstone/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1"
"github.com/FootprintAI/touchstone/internal/fetch"
"github.com/FootprintAI/touchstone/internal/service"
"github.com/santhosh-tekuri/jsonschema/v6"
"google.golang.org/protobuf/encoding/protojson"
Expand All @@ -24,13 +25,35 @@ var (
goldenPath = filepath.Join("..", "..", "testdata", "report.golden.json")
)

func fixedStub() *service.Stub {
func fixedStub(fetch.Options) touchstonev1.ScanServiceServer {
return &service.Stub{Now: func() time.Time { return time.Date(2026, 10, 22, 0, 0, 0, 0, time.UTC) }}
}

func TestScanAllowPrivateFlag(t *testing.T) {
for _, tt := range []struct {
args []string
want bool
}{
{[]string{"scan", "http://127.0.0.1:8080"}, false},
{[]string{"scan", "--allow-private", "http://127.0.0.1:8080"}, true},
} {
var got *fetch.Options
newSvc := func(o fetch.Options) touchstonev1.ScanServiceServer {
got = &o
return fixedStub(o)
}
if err := run(context.Background(), tt.args, &bytes.Buffer{}, newSvc); err != nil {
t.Fatalf("run(%q): %v", tt.args, err)
}
if got == nil || got.AllowPrivate != tt.want {
t.Errorf("run(%q): fetch options %+v, want AllowPrivate=%v", tt.args, got, tt.want)
}
}
}

func TestScanPrintsReportValidAgainstSchema(t *testing.T) {
var out bytes.Buffer
if err := run(context.Background(), []string{"scan", "https://example.com"}, &out, fixedStub()); err != nil {
if err := run(context.Background(), []string{"scan", "https://example.com"}, &out, fixedStub); err != nil {
t.Fatalf("run: %v", err)
}

Expand Down Expand Up @@ -86,8 +109,11 @@ func TestScanPrintsReportValidAgainstSchema(t *testing.T) {
}

func TestScanRejectsBadArgs(t *testing.T) {
for _, args := range [][]string{nil, {"scan"}, {"serve", "https://example.com"}, {"scan", "ftp://example.com"}, {"scan", "example.com"}} {
if err := run(context.Background(), args, &bytes.Buffer{}, fixedStub()); err == nil {
for _, args := range [][]string{
nil, {"scan"}, {"serve", "https://example.com"}, {"scan", "ftp://example.com"}, {"scan", "example.com"},
{"scan", "--allow-private"}, {"scan", "--no-such-flag", "https://example.com"}, {"scan", "https://a.example", "https://b.example"},
} {
if err := run(context.Background(), args, &bytes.Buffer{}, fixedStub); err == nil {
t.Errorf("run(%q) = nil error, want error", args)
}
}
Expand Down
6 changes: 2 additions & 4 deletions internal/fetch/fetch.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// Package fetch defines the Fetcher seam: the one way any other package
// is allowed to read bytes from a vendor site. The real implementation
// (PassiveFetcher, see docs/architecture/touchstone.md) lands in slice
// S1; this package only fixes the interface so S0 callers and their
// tests can be written against it now.
// is allowed to read bytes from a vendor site. PassiveFetcher is the real
// implementation (see docs/architecture/touchstone.md); Fake is for tests.
package fetch

import "context"
Expand Down
Loading
Loading