Skip to content
Open
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: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,17 @@ Committed `.forge` files cannot set the API scheme.

Precedence from highest to lowest: CLI flags, environment variables, `.forge`, `~/.config/forge/config`, built-in defaults.

### Read-only mode

`--read-only`, or `FORGE_READ_ONLY=true` in the environment, stops forge from changing anything on the forge. Only GET, HEAD and OPTIONS requests are sent, so listing and viewing work as usual, and any command that would create, edit or delete something fails before its request leaves the machine. This covers `forge api` too, and `pr create --push` is refused before anything is pushed. It's meant for CI jobs, monitoring and audits that need a token but should never write with it.

```
FORGE_READ_ONLY=true forge issue list
forge --read-only label create bug # Error: create label: Post "...": write refused in read-only mode
```

`--read-only=false` overrides `FORGE_READ_ONLY` for a single command.

## Library

```sh
Expand Down Expand Up @@ -242,6 +253,20 @@ err := client.RegisterDomain(ctx, "git.example.com", token, forges.ForgeBuilders
})
```

For read-only access, wrap the HTTP client with `forges.ReadOnlyHTTPClient` and give it to every backend you construct and to `WithHTTPClient`. Requests other than GET, HEAD and OPTIONS then fail with `forges.ErrReadOnly` without being sent, whichever backend or operation made them:

```go
hc := forges.ReadOnlyHTTPClient(nil)
client := forges.NewClient(
forges.WithHTTPClient(hc),
forges.WithForge("github.com", github.New(token, hc)),
)

f, _ := client.ForgeFor("github.com")
_, err := f.Issues().Create(ctx, "octocat", "hello-world", forges.CreateIssueOpts{Title: "x"})
errors.Is(err, forges.ErrReadOnly) // true
```

PURL support via `github.com/git-pkgs/purl`:

```go
Expand Down
43 changes: 43 additions & 0 deletions bitbucket/readonly_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package bitbucket

import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"

forge "github.com/git-pkgs/forge"
)

func TestBitbucketReadOnlyHTTPClient(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /2.0/user", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"username": "alice"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
})
srv := httptest.NewServer(mux)
defer srv.Close()
origAPI := bitbucketAPI
setBitbucketAPI(srv.URL + "/2.0")
defer setBitbucketAPI(origAPI)

f := New("test-token", forge.ReadOnlyHTTPClient(srv.Client()))
ctx := context.Background()

u, err := f.GetAuthenticatedUser(ctx)
if err != nil {
t.Fatalf("GetAuthenticatedUser: %v", err)
}
assertEqual(t, "Login", "alice", u.Login)

if _, err := f.Issues().Create(ctx, "alice", "hello", forge.CreateIssueOpts{Title: "x"}); !errors.Is(err, forge.ErrReadOnly) {
t.Errorf("Issues().Create: err = %v, want ErrReadOnly", err)
}
if err := f.Repos().Delete(ctx, "alice", "hello"); !errors.Is(err, forge.ErrReadOnly) {
t.Errorf("Repos().Delete: err = %v, want ErrReadOnly", err)
}
}
4 changes: 4 additions & 0 deletions forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ func ClassifyStatus(status int, err error) error {
return &classifiedError{sentinel: sentinel, err: err}
}

// ErrReadOnly is returned when a client created by ReadOnlyHTTPClient refuses
// a request that could modify forge state.
var ErrReadOnly = errors.New("write refused in read-only mode")

// ResourceType identifies the kind of resource a URL points to.
type ResourceType string

Expand Down
42 changes: 42 additions & 0 deletions gerrit/readonly_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package gerrit

import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"

forge "github.com/git-pkgs/forge"
)

func TestReadOnlyHTTPClient(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /a/accounts/self", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, xssi+`{"username": "alice"}`)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
})
srv := httptest.NewServer(mux)
defer srv.Close()

f := New(srv.URL, "alice:secret", forge.ReadOnlyHTTPClient(srv.Client()))
ctx := context.Background()

u, err := f.GetAuthenticatedUser(ctx)
if err != nil {
t.Fatalf("GetAuthenticatedUser: %v", err)
}
if u.Login != "alice" {
t.Errorf("Login = %q, want alice", u.Login)
}

if err := f.PullRequests().Close(ctx, "org", "repo", 42); !errors.Is(err, forge.ErrReadOnly) {
t.Errorf("PullRequests().Close: err = %v, want ErrReadOnly", err)
}
if err := f.Branches().Delete(ctx, "org", "repo", "topic"); !errors.Is(err, forge.ErrReadOnly) {
t.Errorf("Branches().Delete: err = %v, want ErrReadOnly", err)
}
}
40 changes: 40 additions & 0 deletions gitea/readonly_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package gitea

import (
"context"
"encoding/json"
"errors"
forge "github.com/git-pkgs/forge"
"net/http"
"net/http/httptest"
"testing"
)

func TestGiteaReadOnlyHTTPClient(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/version", giteaVersionHandler)
mux.HandleFunc("GET /api/v1/user", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"login": "alice"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
})
srv := httptest.NewServer(mux)
defer srv.Close()

f := New(srv.URL, "test-token", forge.ReadOnlyHTTPClient(srv.Client()))
ctx := context.Background()

u, err := f.GetAuthenticatedUser(ctx)
if err != nil {
t.Fatalf("GetAuthenticatedUser: %v", err)
}
assertEqual(t, "Login", "alice", u.Login)

if _, err := f.Issues().Create(ctx, "alice", "hello", forge.CreateIssueOpts{Title: "x"}); !errors.Is(err, forge.ErrReadOnly) {
t.Errorf("Issues().Create: err = %v, want ErrReadOnly", err)
}
if err := f.Repos().Delete(ctx, "alice", "hello"); !errors.Is(err, forge.ErrReadOnly) {
t.Errorf("Repos().Delete: err = %v, want ErrReadOnly", err)
}
}
39 changes: 39 additions & 0 deletions github/readonly_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package github

import (
"context"
"encoding/json"
"errors"
forge "github.com/git-pkgs/forge"
"net/http"
"net/http/httptest"
"testing"
)

func TestGitHubReadOnlyHTTPClient(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v3/user", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"login": "octocat"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
})
srv := httptest.NewServer(mux)
defer srv.Close()

f := NewWithBase(srv.URL, "test-token", forge.ReadOnlyHTTPClient(srv.Client()))
ctx := context.Background()

u, err := f.GetAuthenticatedUser(ctx)
if err != nil {
t.Fatalf("GetAuthenticatedUser: %v", err)
}
assertEqual(t, "Login", "octocat", u.Login)

if _, err := f.Issues().Create(ctx, "octocat", "hello", forge.CreateIssueOpts{Title: "x"}); !errors.Is(err, forge.ErrReadOnly) {
t.Errorf("Issues().Create: err = %v, want ErrReadOnly", err)
}
if err := f.Repos().Delete(ctx, "octocat", "hello"); !errors.Is(err, forge.ErrReadOnly) {
t.Errorf("Repos().Delete: err = %v, want ErrReadOnly", err)
}
}
42 changes: 42 additions & 0 deletions gitlab/readonly_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//go:build !tinygo

package gitlab

import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"

forge "github.com/git-pkgs/forge"
)

func TestGitLabReadOnlyHTTPClient(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v4/user", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"username": "jdoe"})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
})
srv := httptest.NewServer(mux)
defer srv.Close()

f := New(srv.URL, "test-token", forge.ReadOnlyHTTPClient(srv.Client()))
ctx := context.Background()

u, err := f.GetAuthenticatedUser(ctx)
if err != nil {
t.Fatalf("GetAuthenticatedUser: %v", err)
}
assertEqual(t, "Login", "jdoe", u.Login)

if _, err := f.Issues().Create(ctx, "jdoe", "hello", forge.CreateIssueOpts{Title: "x"}); !errors.Is(err, forge.ErrReadOnly) {
t.Errorf("Issues().Create: err = %v, want ErrReadOnly", err)
}
if err := f.Repos().Delete(ctx, "jdoe", "hello"); !errors.Is(err, forge.ErrReadOnly) {
t.Errorf("Repos().Delete: err = %v, want ErrReadOnly", err)
}
}
5 changes: 5 additions & 0 deletions internal/cli/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import (
func resetCmd(cmd *cobra.Command) {
reset := func(f *pflag.Flag) {
f.Changed = false
// Set on a slice flag would add its "[]" default as an element.
if v, ok := f.Value.(pflag.SliceValue); ok {
_ = v.Replace(nil)
return
}
_ = f.Value.Set(f.DefValue)
}
cmd.Flags().VisitAll(reset)
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,9 @@ func prCreateCmd() *cobra.Command {
Aliases: []string{"new"},
Short: "Create a pull request",
RunE: func(cmd *cobra.Command, args []string) error {
if flagPush && resolve.ReadOnly() {
return fmt.Errorf("--push cannot be used in read-only mode")
}
if flagTitle == "" && !flagFill {
return fmt.Errorf("--title is required")
}
Expand Down
Loading
Loading