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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,22 @@ func loadConfig(ctx context.Context, client *github.Client, owner, repo, ref str
}
```

To load a specific remote file without discovery or remote-reference parsing, use
`LoadRemoteRef`. Missing files are errors; existing empty files retain their
source metadata.

```go
c, err := loader.LoadRemoteRef(ctx, client, clientOwner, appconfig.RemoteRef{
Remote: "example/shared-config",
Path: "config/app.yml",
Ref: "main",
})
```

`clientOwner` must be the supplied client's original installation owner, even if
an earlier load returned a file owned by another organization. Cross-owner
installation access requires `WithPrivateRemotes`

## OAuth2

The `oauth2` package provides an `http.Handler` implementation that simplifies
Expand Down
24 changes: 23 additions & 1 deletion appconfig/appconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"

"github.com/google/go-github/v92/github"
Expand Down Expand Up @@ -55,7 +56,14 @@ func (r RemoteRef) SplitRemote() (owner, repo string, err error) {
if slash <= 0 || slash >= len(r.Remote)-1 {
return "", "", fmt.Errorf("invalid remote value: %s", r.Remote)
}
return r.Remote[:slash], r.Remote[slash+1:], nil
owner, repo = r.Remote[:slash], r.Remote[slash+1:]
// github clients turn these into urls, so ensuring valid URLs
for _, part := range []string{owner, repo} {
if part == "." || part == ".." || url.PathEscape(part) != part {
return "", "", fmt.Errorf("invalid remote value: %s", r.Remote)
}
}
return owner, repo, nil
}

// Config contains unparsed configuration data and metadata about where it was found.
Expand Down Expand Up @@ -163,6 +171,20 @@ func (ld *Loader) LoadConfig(ctx context.Context, client *github.Client, owner,
return Config{}, nil
}

// LoadRemoteRef returns a literal file without parsing references or searching
// other paths or owner defaults. Path and ref defaults are defined by [RemoteRef].
// Missing files are errors; empty files retain their source metadata.
//
// clientOwner must be client's original installation owner, not a loaded file's
// owner. It is trusted caller context. See [WithPrivateRemotes] for cross-owner
// access and its information disclosure warning.
//
// On load errors, Config identifies the remote file, with a ref once resolved.
// An invalid remote repository value returns an empty Config and an error.
func (ld *Loader) LoadRemoteRef(ctx context.Context, client *github.Client, clientOwner string, remote RemoteRef) (Config, error) {
return ld.loadRemoteConfig(ctx, client, clientOwner, remote, Config{})
}

func (ld *Loader) loadRemoteConfig(ctx context.Context, client *github.Client, sourceOwner string, remote RemoteRef, c Config) (Config, error) {
logger := zerolog.Ctx(ctx)
notFoundErr := fmt.Errorf("invalid remote reference: file does not exist")
Expand Down
3 changes: 2 additions & 1 deletion appconfig/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import (
type Option func(*Loader)

// WithRemoteRefParser sets the parser for encoded RemoteRefs. The default
// parser uses YAML. Set a nil parser to disable remote references.
// parser uses YAML. Set a nil parser to disable automatic remote references in
// LoadConfig.
func WithRemoteRefParser(parser RemoteRefParser) Option {
return func(ld *Loader) {
ld.parser = parser
Expand Down
245 changes: 245 additions & 0 deletions appconfig/remote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
// Copyright 2026 Palantir Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package appconfig

import (
"context"
"encoding/base64"
"errors"
"net/http"
"reflect"
"testing"

"github.com/google/go-github/v92/github"
"github.com/palantir/go-githubapp/githubapp"
)

type remoteRoundTripperFunc func(*http.Request) (*http.Response, error)

func (f remoteRoundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

func newRemoteTestClient(t *testing.T, rt http.RoundTripper) *github.Client {
t.Helper()
client, err := github.NewClient(github.WithHTTPClient(&http.Client{Transport: rt}))
if err != nil {
t.Fatal(err)
}
return client
}

func TestLoadRemoteRefLiteral(t *testing.T) {
content := "remote: remote/config\npath: first.yml\nref: main\n"
var requests []string
client := newRemoteTestClient(t, remoteRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
requests = append(requests, req.URL.RequestURI())
response := SavedResponse{Status: http.StatusOK, Body: `{"default_branch":"main"}`}
if req.URL.Path != "/repos/remote/config" {
response.Body = `{"encoding":"base64","content":"` + base64.StdEncoding.EncodeToString([]byte(content)) + `"}`
}
return response.Response(req), nil
}))
loader := NewLoader([]string{"first.yml", "second.yml"}, WithRemoteRefParser(func(string, []byte) (*RemoteRef, error) {
t.Error("literal loading invoked the remote parser")
return nil, errors.New("unexpected parser call")
}))

cfg, err := loader.LoadRemoteRef(context.Background(), client, "original", RemoteRef{Remote: "remote/config"})
if err != nil {
t.Fatal(err)
}
want := Config{Content: []byte(content), Source: "remote/config@main", Path: "first.yml", IsRemote: true}
if !reflect.DeepEqual(cfg, want) {
t.Errorf("got %+v, want %+v", cfg, want)
}
wantRequests := []string{"/repos/remote/config", "/repos/remote/config/contents/first.yml?ref=main"}
if !reflect.DeepEqual(requests, wantRequests) {
t.Errorf("requests: got %v, want %v", requests, wantRequests)
}
}

func TestLoadRemoteRefMissingAndEmpty(t *testing.T) {
for name, test := range map[string]struct {
response SavedResponse
missing bool
}{
"missing target": {response: SavedResponse{Status: http.StatusNotFound}, missing: true},
"directory": {response: SavedResponse{Status: http.StatusOK, Body: `[]`}, missing: true},
"empty file": {response: SavedResponse{Status: http.StatusOK, Body: `{"encoding":"base64","content":""}`}},
} {
t.Run(name, func(t *testing.T) {
calls := 0
client := newRemoteTestClient(t, remoteRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
calls++
if req.URL.RequestURI() != "/repos/remote/config/contents/first.yml?ref=main" {
t.Errorf("unexpected request: %s", req.URL)
}
return test.response.Response(req), nil
}))
loader := NewLoader([]string{"first.yml", "second.yml"})
cfg, err := loader.LoadRemoteRef(context.Background(), client, "original", RemoteRef{Remote: "remote/config", Ref: "main"})
if test.missing {
if err == nil || err.Error() != "invalid remote reference: file does not exist" {
t.Fatalf("expected missing remote error, got %v", err)
}
} else if err != nil {
t.Fatal(err)
}
if calls != 1 || cfg.IsUndefined() || len(cfg.Content) != 0 || cfg.Source != "remote/config@main" || cfg.Path != "first.yml" || !cfg.IsRemote {
t.Errorf("unexpected config or request count: %+v, %d", cfg, calls)
}
})
}
}

func TestLoadRemoteRefInvalidRepository(t *testing.T) {
for _, remote := range []string{
"", "owner", "/repo", "owner/", "owner/repo/extra",
"owner/repo#", "owner/repo?x=", "owner/repo/contents/other.yml#",
"owner#fragment/repo", "owner%2frepo/name", "owner/repo%23fragment",
"./repo", "../repo", "owner/.", "owner/..", "owner/%2e%2e",
"https://example.invalid/repo", "owner/repo\\extra", "owner/repo name",
} {
t.Run(remote, func(t *testing.T) {
client := newRemoteTestClient(t, remoteRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
t.Error("invalid remote made an HTTP request")
// Query/fragment injection previously made this look like an empty file.
return (&SavedResponse{Status: http.StatusOK, Body: `{"name":"repo"}`}).Response(req), nil
}))
installations := &testInstallationsService{}
creator := &testClientCreator{client: client}
loader := NewLoader(nil, WithPrivateRemotes(creator, installations))
cfg, err := loader.LoadRemoteRef(context.Background(), client, "original", RemoteRef{Remote: remote, Path: "app.yml", Ref: "main"})
if err == nil || !reflect.DeepEqual(cfg, Config{}) {
t.Errorf("expected invalid remote error and empty Config, got %+v, %v", cfg, err)
}
if installations.calls != 0 || creator.calls != 0 {
t.Errorf("invalid remote triggered installation lookup/client creation: %d, %d", installations.calls, creator.calls)
}
})
}
}

func TestLoadRemoteRefSuccessivePrivateFiles(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
original := newRemoteTestClient(t, remoteRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
t.Error("used original client for private remote")
return errorResponse(req, http.StatusForbidden, "denied")
}))
var requests []string
selected := newRemoteTestClient(t, remoteRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
requests = append(requests, req.URL.String())
if req.Context() != ctx {
t.Error("selected client lost caller context")
}
response := SavedResponse{Status: http.StatusOK}
switch req.URL.Path {
case "/repos/remote/config/contents/child.yml":
response.Body = `{"encoding":"base64","content":"Y2hpbGQ="}`
case "/repos/remote/config/contents/parent.yml":
response.Body = `{"encoding":"none","download_url":"https://download.example/parent"}`
case "/parent":
response.Body = "parent"
}
return response.Response(req), nil
}))
installations := &testInstallationsService{installation: githubapp.Installation{ID: 42}}
creator := &testClientCreator{client: selected}
loader := NewLoader([]string{"unused.yml"}, WithPrivateRemotes(creator, installations))

for _, file := range []string{"child", "parent"} {
cfg, err := loader.LoadRemoteRef(ctx, original, "source", RemoteRef{Remote: "remote/config", Path: file + ".yml", Ref: "main"})
if err != nil || string(cfg.Content) != file || cfg.Source != "remote/config@main" || cfg.Path != file+".yml" || !cfg.IsRemote {
t.Fatalf("incorrect remote config: %+v, %v", cfg, err)
}
}
if installations.calls != 2 || installations.owner != "remote" || installations.repo != "config" || creator.calls != 2 || creator.installationID != 42 {
t.Errorf("incorrect installation selection: lookup=%+v creator=%+v", installations, creator)
}
wantRequests := []string{
"https://api.github.com/repos/remote/config/contents/child.yml?ref=main",
"https://api.github.com/repos/remote/config/contents/parent.yml?ref=main",
"https://download.example/parent",
}
if !reflect.DeepEqual(requests, wantRequests) {
t.Errorf("requests: got %v, want %v", requests, wantRequests)
}
}

func TestLoadRemoteRefAccessFailures(t *testing.T) {
for name, test := range map[string]struct {
lookupErr, creatorErr error
originalStatus int
originalCalls, clientCalls int
creations int
}{
"lookup failure": {lookupErr: githubapp.InstallationNotFound("remote/config"), originalStatus: http.StatusForbidden, originalCalls: 1},
"creation failure": {creatorErr: errors.New("creation failed"), originalStatus: http.StatusForbidden, originalCalls: 1, creations: 1},
"read failure": {originalStatus: http.StatusOK, clientCalls: 1, creations: 1},
} {
t.Run(name, func(t *testing.T) {
originalCalls, selectedCalls := 0, 0
original := newRemoteTestClient(t, remoteRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
originalCalls++
return (&SavedResponse{Status: test.originalStatus, Body: `{"encoding":"base64","content":""}`}).Response(req), nil
}))
selected := newRemoteTestClient(t, remoteRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
selectedCalls++
return errorResponse(req, http.StatusForbidden, "denied")
}))
installations := &testInstallationsService{installation: githubapp.Installation{ID: 42}, err: test.lookupErr}
creator := &testClientCreator{client: selected, err: test.creatorErr}
loader := NewLoader(nil, WithPrivateRemotes(creator, installations))
_, err := loader.LoadRemoteRef(context.Background(), original, "source", RemoteRef{Remote: "remote/config", Path: "app.yml", Ref: "main"})
var responseErr *github.ErrorResponse
if !errors.As(err, &responseErr) || responseErr.Response.StatusCode != http.StatusForbidden {
t.Fatalf("expected access denial, got %v", err)
}
if originalCalls != test.originalCalls || selectedCalls != test.clientCalls || installations.calls != 1 || creator.calls != test.creations {
t.Errorf("incorrect access attempts: original=%d selected=%d lookup=%d creation=%d", originalCalls, selectedCalls, installations.calls, creator.calls)
}
})
}
}

func TestLoadConfigInvalidRemoteDiagnostics(t *testing.T) {
content := "remote: remote/config#\n"
client := newRemoteTestClient(t, remoteRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
response := SavedResponse{Status: http.StatusOK}
if req.URL.Path == "/repos/original/.github" {
response.Body = `{"name":".github","default_branch":"main"}`
} else {
response.Body = `{"encoding":"base64","content":"` + base64.StdEncoding.EncodeToString([]byte(content)) + `"}`
}
return response.Response(req), nil
}))
for _, test := range []struct {
name, source string
paths []string
}{
{name: "local", source: "original/local@main", paths: []string{"app.yml"}},
{name: "owner default", source: "original/.github@main"},
} {
t.Run(test.name, func(t *testing.T) {
loader := NewLoader(test.paths, WithOwnerDefault(".github", []string{"app.yml"}))
cfg, err := loader.LoadConfig(context.Background(), client, "original", "local", "main")
if err == nil || err.Error() != "invalid remote value: remote/config#" || cfg.Source != test.source || cfg.Path != "app.yml" || cfg.IsRemote {
t.Fatalf("lost originating file diagnostics: %+v, %v", cfg, err)
}
})
}
}
Loading