Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4302e5e
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
83c4b41
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
9356839
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
4a75138
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
a9b608c
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
f677afc
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
70e1ef5
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
ca23afc
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
c99be44
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
38c3032
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
946010f
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
b66a02c
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
58b9f15
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
29b6487
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
58c6e08
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
159ecb5
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
3dd8401
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
cb44511
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
474c56c
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
e5a2a92
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
7628ec5
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
9e7fcf6
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
5f035de
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
89059e7
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
87a81f4
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
e594ddd
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
2af19cc
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
401b36e
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
c9d69f5
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
e41ba4e
feat(web): static Next.js report page and touchstone serve (#9)
containarium-ci[bot] Oct 24, 2026
b602a86
feat(web): Next.js static page and touchstone serve (#9)
containarium-ci[bot] Oct 26, 2026
d4f01b3
fix(web): restore the web files of the previous attempt, keep the rea…
hsinatfootprintai Oct 26, 2026
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
node_modules/
# next build output and its generated type shim; web/out is embedded at build time
web/.next/
web/next-env.d.ts
web/out/*
!web/out/.gitkeep
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: generate lint breaking test web-test
.PHONY: generate lint breaking test web-test web-build

# Requires buf (https://buf.build): regenerates gen/, web/src/gen/,
# openapi/ and schema/ from proto/touchstone/v1/scan.proto, using the
Expand All @@ -22,3 +22,8 @@ test:

web-test:
cd web && npm install --no-package-lock && npx tsc --noEmit && npx vitest run

# Exports the page to web/out, which web/embed.go embeds into touchstone serve.
# Build it before `go build ./cmd/touchstone`.
web-build:
cd web && npm install --no-package-lock && npx next build
8 changes: 6 additions & 2 deletions cmd/touchstone/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Command touchstone scans a vendor URL and prints an evidence-graded
// ScanReport as protojson (--format json, the default) or as Markdown
// (--format md). The scan fetches the page and its scripts passively and
// (--format md), or serves the web page and POST /v1/scans (serve, slice
// S9). The scan fetches the page and its scripts passively and
// looks components up in OSV (slice S2).
package main

Expand All @@ -20,7 +21,7 @@ import (
"google.golang.org/protobuf/encoding/protojson"
)

const usage = "usage: touchstone scan [--allow-private] [--format json|md] <url>"
const usage = "usage: touchstone scan [--allow-private] [--format json|md] <url>\n touchstone serve [--addr host:port]"

// newService builds the ScanService for one CLI invocation. The fetch
// options carry CLI-only settings such as --allow-private, which the
Expand All @@ -38,6 +39,9 @@ func main() {
}

func run(ctx context.Context, args []string, out io.Writer, newSvc newService) error {
if len(args) > 0 && args[0] == "serve" {
return serve(ctx, args[1:], out, newSvc)
}
if len(args) == 0 || args[0] != "scan" {
return errors.New(usage)
}
Expand Down
98 changes: 98 additions & 0 deletions cmd/touchstone/serve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package main

import (
"context"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"os"
"time"

touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1"
"github.com/FootprintAI/touchstone/internal/fetch"
"github.com/FootprintAI/touchstone/internal/service"
"github.com/FootprintAI/touchstone/web"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/protobuf/encoding/protojson"
)

const (
defaultAddr = "127.0.0.1:8080"

// scanTimeout bounds one scan request end to end.
scanTimeout = 180 * time.Second
)

// serve runs the embedded page and POST /v1/scans on one port. It has no
// --allow-private: the fetch options carry only the tool version, so the
// server always refuses loopback and private targets.
func serve(ctx context.Context, args []string, out io.Writer, newSvc newService) error {
flags := flag.NewFlagSet("serve", flag.ContinueOnError)
flags.SetOutput(io.Discard)
addr := flags.String("addr", envOr("TOUCHSTONE_ADDR", defaultAddr), "listen address (env TOUCHSTONE_ADDR)")
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
return errors.New(usage)
}
handler, err := newHandler(ctx, newSvc(fetch.Options{Version: service.ToolVersion}), web.Page())
if err != nil {
return err
}
ln, err := net.Listen("tcp", *addr)
if err != nil {
return err
}
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: scanTimeout + 10*time.Second,
}
fmt.Fprintf(out, "touchstone: listening on http://%s\n", ln.Addr())

errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
select {
case err := <-errc:
return err
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdown)
}
}

// newHandler routes /v1/ to the ScanService through grpc-gateway and
// everything else to the static page.
func newHandler(ctx context.Context, svc touchstonev1.ScanServiceServer, page fs.FS) (http.Handler, error) {
// Default protojson options, so the REST body has the same shape as the
// CLI's JSON that the page decodes with the generated fromJson.
gw := runtime.NewServeMux(runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{
MarshalOptions: protojson.MarshalOptions{},
UnmarshalOptions: protojson.UnmarshalOptions{DiscardUnknown: true},
}))
if err := touchstonev1.RegisterScanServiceHandlerServer(ctx, gw, svc); err != nil {
return nil, err
}
mux := http.NewServeMux()
mux.Handle("/v1/", withTimeout(gw, scanTimeout))
mux.Handle("/", http.FileServerFS(page))
return mux, nil
}

func withTimeout(next http.Handler, d time.Duration) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}

func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
145 changes: 145 additions & 0 deletions cmd/touchstone/serve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package main

import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"testing/fstest"
"time"

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

func testServer(t *testing.T) *httptest.Server {
t.Helper()
page := fstest.MapFS{"index.html": {Data: []byte("<h1>Touchstone</h1>")}}
h, err := newHandler(context.Background(), fixedStub(fetch.Options{}), page)
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
return srv
}

func TestServeServesPage(t *testing.T) {
srv := testServer(t)
resp, err := http.Get(srv.URL + "/")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body bytes.Buffer
body.ReadFrom(resp.Body)
if resp.StatusCode != 200 || !strings.Contains(body.String(), "<h1>Touchstone</h1>") {
t.Errorf("GET / = %d %q, want the embedded page", resp.StatusCode, body.String())
}
}

// TestServeScanMatchesGolden posts through grpc-gateway and compares the
// body with the same golden the CLI test and the Vitest suite use.
func TestServeScanMatchesGolden(t *testing.T) {
srv := testServer(t)
resp, err := http.Post(srv.URL+"/v1/scans", "application/json", strings.NewReader(`{"url":"https://example.com"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var body bytes.Buffer
body.ReadFrom(resp.Body)
if resp.StatusCode != 200 {
t.Fatalf("POST /v1/scans = %d: %s", resp.StatusCode, body.String())
}
var got, want touchstonev1.ScanReport
if err := protojson.Unmarshal(body.Bytes(), &got); err != nil {
t.Fatalf("response is not a ScanReport: %v\n%s", err, body.String())
}
golden, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatal(err)
}
if err := protojson.Unmarshal(golden, &want); err != nil {
t.Fatal(err)
}
if !proto.Equal(&got, &want) {
t.Errorf("gateway report differs from %s:\n%s", goldenPath, body.String())
}
}

func TestServeScanRejectsBadURL(t *testing.T) {
srv := testServer(t)
resp, err := http.Post(srv.URL+"/v1/scans", "application/json", strings.NewReader(`{"url":"example.com"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("POST bad url = %d, want 400", resp.StatusCode)
}
}

func TestServeRejectsBadArgs(t *testing.T) {
for _, args := range [][]string{
{"serve", "https://example.com"}, {"serve", "--allow-private"}, {"serve", "--no-such-flag"},
} {
if err := run(context.Background(), args, &bytes.Buffer{}, fixedStub); err == nil {
t.Errorf("run(%q) = nil error, want error", args)
}
}
}

func TestServeListensAndStops(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
out := &syncBuffer{}
done := make(chan error, 1)
go func() { done <- run(ctx, []string{"serve", "--addr", "127.0.0.1:0"}, out, fixedStub) }()
for !strings.Contains(out.String(), "listening on http://127.0.0.1:") {
select {
case err := <-done:
t.Fatalf("serve exited early: %v", err)
default:
time.Sleep(5 * time.Millisecond)
}
}
cancel()
if err := <-done; err != nil {
t.Errorf("serve after cancel: %v", err)
}
}

// syncBuffer is a bytes.Buffer safe to read while serve writes to it.
type syncBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}

func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}

func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}

func TestServeDefaultAddr(t *testing.T) {
t.Setenv("TOUCHSTONE_ADDR", "")
if got := envOr("TOUCHSTONE_ADDR", defaultAddr); got != "127.0.0.1:8080" {
t.Errorf("default addr = %q, want 127.0.0.1:8080", got)
}
t.Setenv("TOUCHSTONE_ADDR", "127.0.0.1:9")
if got := envOr("TOUCHSTONE_ADDR", defaultAddr); got != "127.0.0.1:9" {
t.Errorf("env addr = %q", got)
}
}
30 changes: 30 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Package server is the HTTP face of touchstone serve. The ScanService is
// exposed under /v1/ through grpc-gateway, which calls the service in
// process; every other path serves the embedded static web page.
package server

import (
"context"
"io/fs"
"net/http"

touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
)

// maxRequestBytes caps a request body. A ScanRequest carrying only a URL is
// a few hundred bytes; sbom_document uploads (D1) will need a larger cap.
const maxRequestBytes = 1 << 20

// New returns the handler for touchstone serve: svc under /v1/ and static
// at every other path.
func New(ctx context.Context, svc touchstonev1.ScanServiceServer, static fs.FS) (http.Handler, error) {
gw := runtime.NewServeMux()
if err := touchstonev1.RegisterScanServiceHandlerServer(ctx, gw, svc); err != nil {
return nil, err
}
mux := http.NewServeMux()
mux.Handle("/v1/", http.MaxBytesHandler(gw, maxRequestBytes))
mux.Handle("/", http.FileServerFS(static))
return mux, nil
}
Loading
Loading