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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ func main() {
// ... populate evaluation log ...
}

sarifBytes, err := gemaraconv.EvaluationLog(evaluationLog).ToSARIF("file:///path/to/artifact.md", catalog)
sarifBytes, err := gemaraconv.EvaluationLog(evaluationLog).ToSARIF("path/to/artifact.md", catalog)
if err != nil {
panic(err)
}
Expand Down
35 changes: 24 additions & 11 deletions fetcher/uri.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,41 @@ import (
"io"
"net/http"
"net/url"
"regexp"
"strings"
)

// URI routes to File or HTTP based on the URI scheme.
// Supported schemes: file://, http://, https://.
// URI routes to File or HTTP based on the source string.
//
// Recognized forms:
// - http:// or https:// URLs are fetched via [HTTP].
// - file:// URIs are fetched via [File].
// - Any other input without a scheme (absolute or relative local paths,
// including Windows drive paths) is treated as a local file path.
// - Inputs with any other <scheme>:// prefix return an unsupported-scheme error.
//
// For HTTP(S) sources it delegates to [HTTP]; see that type's
// documentation for security considerations.
type URI struct {
Client *http.Client
}

// schemePrefix matches a leading "<scheme>://" per RFC 3986 scheme syntax.
var schemePrefix = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.\-]*://`)

func (u *URI) Fetch(ctx context.Context, source string) (io.ReadCloser, error) {
parsed, err := url.Parse(source)
if err != nil {
return nil, fmt.Errorf("invalid URI %q: %w", source, err)
}
switch parsed.Scheme {
case "file":
return (&File{}).Fetch(ctx, parsed.Path)
case "http", "https":
switch {
case strings.HasPrefix(source, "http://"), strings.HasPrefix(source, "https://"):
return (&HTTP{Client: u.Client}).Fetch(ctx, source)
default:
case strings.HasPrefix(source, "file://"):
parsed, err := url.Parse(source)
if err != nil {
return nil, fmt.Errorf("invalid file URI %q: %w", source, err)
}
return (&File{}).Fetch(ctx, parsed.Path)
case schemePrefix.MatchString(source):
return nil, fmt.Errorf("unsupported URI scheme in %q", source)
default:
return (&File{}).Fetch(ctx, source)
}
}
37 changes: 37 additions & 0 deletions fetcher/uri_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,40 @@ func TestURI_UnsupportedScheme(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported URI scheme")
}

func TestURI_BarePath_Absolute(t *testing.T) {
tmp := t.TempDir()
p := filepath.Join(tmp, "data.yaml")
require.NoError(t, os.WriteFile(p, []byte("ok: true\n"), 0600))

f := &URI{}
rc, err := f.Fetch(context.Background(), p)
require.NoError(t, err)
defer rc.Close() //nolint:errcheck

data, err := io.ReadAll(rc)
require.NoError(t, err)
assert.Equal(t, "ok: true\n", string(data))
}

func TestURI_BarePath_Relative(t *testing.T) {
tmp := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(tmp, "data.yaml"), []byte("ok: true\n"), 0600))
t.Chdir(tmp)

f := &URI{}
rc, err := f.Fetch(context.Background(), "./data.yaml")
require.NoError(t, err)
defer rc.Close() //nolint:errcheck

data, err := io.ReadAll(rc)
require.NoError(t, err)
assert.Equal(t, "ok: true\n", string(data))
}

func TestURI_TypoScheme(t *testing.T) {
f := &URI{}
_, err := f.Fetch(context.Background(), "htps://example.com/file.yaml")
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported URI scheme")
}
8 changes: 4 additions & 4 deletions gemaraconv/markdown/lexicon_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import (
"github.com/gemaraproj/go-gemara/internal/codec"
)

// resolveLexiconURL returns the https:// or file:// URI for the lexicon artifact.
// resolveLexiconURL returns the source string (URL or local path) for the lexicon artifact.
// Precedence: metadata.mapping-references entry whose id matches metadata.lexicon.reference-id;
// else metadata.lexicon.remarks if it is a fetchable URL.
// else metadata.lexicon.remarks if it is a fetchable URL (must use http://, https://, or file://).
func resolveLexiconURL(meta gemara.Metadata) (string, error) {
if meta.Lexicon == nil {
return "", fmt.Errorf("lexicon mapping is nil")
Expand All @@ -37,8 +37,8 @@ func resolveLexiconURL(meta gemara.Metadata) (string, error) {
return "", fmt.Errorf("no mapping-references entry with id %q for metadata.lexicon", refID)
}

// loadLexiconFromURI fetches a Lexicon from a file:// or http(s):// URI
// and returns normalized entries.
// loadLexiconFromURI fetches a Lexicon from an http(s):// URL, a file:// URI,
// or a local file path, and returns normalized entries.
func loadLexiconFromURI(ctx context.Context, uri string) ([]lexiconEntry, error) {
doc, err := gemara.Load[gemara.Lexicon](ctx, &fetcher.URI{}, uri)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion gemaraconv/markdown/lexicon_load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func TestParseLexiconYAML_rejects(t *testing.T) {
}

func TestLoadLexiconFromURI_file(t *testing.T) {
entries, err := loadLexiconFromURI(context.Background(), lexiconFileURL(t, "lexicon_good.yaml"))
entries, err := loadLexiconFromURI(context.Background(), lexiconTestdataAbsPath(t, "lexicon_good.yaml"))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
entries, err := loadLexiconFromURI(context.Background(), lexiconTestdataAbsPath(t, "lexicon_good.yaml"))
entries, err := loadLexiconFromURI(context.Background(), lexiconTestDataAbsPath(t, "lexicon_good.yaml"))

Same comment for consistent kebabCase.

require.NoError(t, err)
require.Len(t, entries, 2)
}
Expand Down
5 changes: 0 additions & 5 deletions gemaraconv/markdown/lexicon_testdata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,3 @@ func readLexiconTestdata(t *testing.T, name string) []byte {
require.NoError(t, err)
return fileBytes
}

func lexiconFileURL(t *testing.T, name string) string {
t.Helper()
return "file://" + filepath.ToSlash(lexiconTestdataAbsPath(t, name))
}
2 changes: 1 addition & 1 deletion gemaraconv/markdown/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ func TestCatalogToMarkdown_lexiconAutolinkFromFile(t *testing.T) {
Id: "m", Type: gemara.ControlCatalogArtifact, Description: "d", Author: gemara.Actor{Name: "a", Type: gemara.Human},
Lexicon: &gemara.ArtifactMapping{ReferenceId: "lex"},
MappingReferences: []gemara.MappingReference{
{Id: "lex", Title: "L", Version: "1", Url: lexiconFileURL(t, "lexicon_good.yaml")},
{Id: "lex", Title: "L", Version: "1", Url: lexiconTestdataAbsPath(t, "lexicon_good.yaml")},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{Id: "lex", Title: "L", Version: "1", Url: lexiconTestdataAbsPath(t, "lexicon_good.yaml")},
{Id: "lex", Title: "L", Version: "1", Url: lexiconTestDataAbsPath(t, "lexicon_good.yaml")},

@eddie-knight this may be a nit, but the lexiconTestdataAbsPath is not kebab-case consistent. I'd suggest replacing with lexiconTestDataAbsPath.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't a required change. Nevermind. Testdata is considered a single word.

},
},
Title: "Lex",
Expand Down
10 changes: 5 additions & 5 deletions gemaraconv/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ import (
"github.com/stretchr/testify/require"
)

// testDataFileURL returns a file:// URI to ../test-data/<name> resolved to an absolute path.
func testDataFileURL(t *testing.T, name string) string {
// testDataFilePath returns the absolute path to ../test-data/<name>.
func testDataFilePath(t *testing.T, name string) string {
t.Helper()
abs, err := filepath.Abs(filepath.Join("..", "test-data", name))
require.NoError(t, err)
return "file://" + filepath.ToSlash(abs)
return abs
}

func loadControlCatalogFromTestData(t *testing.T, name string) *gemara.ControlCatalog {
Expand Down Expand Up @@ -345,7 +345,7 @@ func TestCatalogToMarkdown_lexiconAutolink(t *testing.T) {
GemaraVersion: "1.0",
Lexicon: &gemara.ArtifactMapping{ReferenceId: "lex"},
MappingReferences: []gemara.MappingReference{
{Id: "lex", Title: "Lex", Version: "1", Url: testDataFileURL(t, "lexicon_good.yaml")},
{Id: "lex", Title: "Lex", Version: "1", Url: testDataFilePath(t, "lexicon_good.yaml")},
},
},
Title: "Lex test",
Expand Down Expand Up @@ -389,7 +389,7 @@ func TestCatalogToMarkdown_lexiconAutolink_offByDefault(t *testing.T) {
Author: gemara.Actor{Name: "a", Type: gemara.Human},
Lexicon: &gemara.ArtifactMapping{ReferenceId: "lex"},
MappingReferences: []gemara.MappingReference{
{Id: "lex", Title: "L", Version: "1", Url: testDataFileURL(t, "lexicon_good.yaml")},
{Id: "lex", Title: "L", Version: "1", Url: testDataFilePath(t, "lexicon_good.yaml")},
},
},
Title: "x",
Expand Down