Refactor Gemini protocol implementation and improve server architecture
- Move gemini URL parsing from common/ to gemini/ package - Add structured status codes in gemini/status_codes.go - Improve error handling with proper Gemini status codes - Update configuration field naming (Listen -> ListenAddr) - Add UTF-8 validation for URLs - Enhance security with better path validation - Add CLAUDE.md for development guidance - Include example content in srv/ directory - Update build system to use standard shell
This commit is contained in:
34
gemini/status_codes.go
Normal file
34
gemini/status_codes.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package gemini
|
||||
|
||||
// Gemini status codes as defined in the Gemini spec
|
||||
// gemini://geminiprotocol.net/docs/protocol-specification.gmi
|
||||
const (
|
||||
// Input group
|
||||
StatusInputExpected = 10
|
||||
StatusInputExpectedSensitive = 11
|
||||
|
||||
StatusSuccess = 20
|
||||
|
||||
// Redirect group
|
||||
StatusRedirectTemporary = 30
|
||||
StatusRedirectPermanent = 31
|
||||
|
||||
// Temporary failure group
|
||||
StatusTemporaryFailure = 40
|
||||
StatusServerUnavailable = 41
|
||||
StatusCGIError = 42
|
||||
StatusProxyError = 43
|
||||
StatusSlowDown = 44
|
||||
|
||||
// Permanent failure group
|
||||
StatusPermanentFailure = 50
|
||||
StatusNotFound = 51
|
||||
StatusGone = 52
|
||||
StatusProxyRequestRefused = 53
|
||||
StatusBadRequest = 59
|
||||
|
||||
// TLS certificate group
|
||||
StatusCertificateRequired = 60
|
||||
StatusCertificateNotAuthorized = 61
|
||||
StatusCertificateNotValid = 62
|
||||
)
|
||||
240
gemini/url.go
Normal file
240
gemini/url.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.antanst.com/antanst/xerrors"
|
||||
)
|
||||
|
||||
type URL struct {
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Descr string `json:"descr,omitempty"`
|
||||
Full string `json:"full,omitempty"`
|
||||
}
|
||||
|
||||
func (u *URL) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
// Clear the fields in the current GeminiUrl object (not the pointer itself)
|
||||
*u = URL{}
|
||||
return nil
|
||||
}
|
||||
b, ok := value.(string)
|
||||
if !ok {
|
||||
return xerrors.NewError(fmt.Errorf("database scan error: expected string, got %T", value), 0, "Database scan error", true)
|
||||
}
|
||||
parsedURL, err := ParseURL(b, "", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*u = *parsedURL
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u URL) String() string {
|
||||
return u.Full
|
||||
}
|
||||
|
||||
func (u URL) StringNoDefaultPort() string {
|
||||
if u.Port == 1965 {
|
||||
return fmt.Sprintf("%s://%s%s", u.Protocol, u.Hostname, u.Path)
|
||||
}
|
||||
return u.Full
|
||||
}
|
||||
|
||||
func (u URL) Value() (driver.Value, error) {
|
||||
if u.Full == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return u.Full, nil
|
||||
}
|
||||
|
||||
func ParseURL(input string, descr string, normalize bool) (*URL, error) {
|
||||
var u *url.URL
|
||||
var err error
|
||||
if normalize {
|
||||
u, err = NormalizeURL(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
u, err = url.Parse(input)
|
||||
if err != nil {
|
||||
return nil, xerrors.NewError(fmt.Errorf("error parsing URL: %w: %s", err, input), 0, "URL parse error", false)
|
||||
}
|
||||
}
|
||||
|
||||
protocol := u.Scheme
|
||||
hostname := u.Hostname()
|
||||
strPort := u.Port()
|
||||
urlPath := u.EscapedPath()
|
||||
if strPort == "" {
|
||||
strPort = "1965"
|
||||
}
|
||||
port, err := strconv.Atoi(strPort)
|
||||
if err != nil {
|
||||
return nil, xerrors.NewError(fmt.Errorf("error parsing URL: %w: %s", err, input), 0, "URL parse error", false)
|
||||
}
|
||||
full := fmt.Sprintf("%s://%s:%d%s", protocol, hostname, port, urlPath)
|
||||
// full field should also contain query params and url fragments
|
||||
if u.RawQuery != "" {
|
||||
full += "?" + u.RawQuery
|
||||
}
|
||||
if u.Fragment != "" {
|
||||
full += "#" + u.Fragment
|
||||
}
|
||||
return &URL{Protocol: protocol, Hostname: hostname, Port: port, Path: urlPath, Descr: descr, Full: full}, nil
|
||||
}
|
||||
|
||||
// DeriveAbsoluteURL converts a (possibly) relative
|
||||
// URL to an absolute one. Used primarily to calculate
|
||||
// the full redirection URL target from a response header.
|
||||
func DeriveAbsoluteURL(currentURL URL, input string) (*URL, error) {
|
||||
// If target URL is absolute, return just it
|
||||
if strings.Contains(input, "://") {
|
||||
return ParseURL(input, "", true)
|
||||
}
|
||||
// input is a relative path. Clean it and construct absolute.
|
||||
var newPath string
|
||||
// Handle weird cases found in the wild
|
||||
if strings.HasPrefix(input, "/") {
|
||||
newPath = path.Clean(input)
|
||||
} else if input == "./" || input == "." {
|
||||
newPath = path.Join(currentURL.Path, "/")
|
||||
} else {
|
||||
newPath = path.Join(currentURL.Path, "/", path.Clean(input))
|
||||
}
|
||||
strURL := fmt.Sprintf("%s://%s:%d%s", currentURL.Protocol, currentURL.Hostname, currentURL.Port, newPath)
|
||||
return ParseURL(strURL, "", true)
|
||||
}
|
||||
|
||||
// NormalizeURL takes a URL string and returns a normalized version
|
||||
// Normalized meaning:
|
||||
// - Path normalization (removing redundant slashes, . and .. segments)
|
||||
// - Proper escaping of special characters
|
||||
// - Lowercase scheme and host
|
||||
// - Removal of default ports
|
||||
// - Empty path becomes "/"
|
||||
func NormalizeURL(rawURL string) (*url.URL, error) {
|
||||
// Parse the URL
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, xerrors.NewError(fmt.Errorf("error normalizing URL: %w: %s", err, rawURL), 0, "URL normalization error", false)
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
return nil, xerrors.NewError(fmt.Errorf("error normalizing URL: No scheme: %s", rawURL), 0, "Missing URL scheme", false)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, xerrors.NewError(fmt.Errorf("error normalizing URL: No host: %s", rawURL), 0, "Missing URL host", false)
|
||||
}
|
||||
|
||||
// Convert scheme to lowercase
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
|
||||
// Convert hostname to lowercase
|
||||
if u.Host != "" {
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
}
|
||||
|
||||
// Remove default ports
|
||||
if u.Port() != "" {
|
||||
switch {
|
||||
case u.Scheme == "http" && u.Port() == "80":
|
||||
u.Host = u.Hostname()
|
||||
case u.Scheme == "https" && u.Port() == "443":
|
||||
u.Host = u.Hostname()
|
||||
case u.Scheme == "gemini" && u.Port() == "1965":
|
||||
u.Host = u.Hostname()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle path normalization while preserving trailing slash
|
||||
if u.Path != "" {
|
||||
// Check if there was a trailing slash before cleaning
|
||||
hadTrailingSlash := strings.HasSuffix(u.Path, "/")
|
||||
|
||||
u.Path = path.Clean(u.EscapedPath())
|
||||
// If path was "/", path.Clean() will return "."
|
||||
if u.Path == "." {
|
||||
u.Path = "/"
|
||||
} else if hadTrailingSlash && u.Path != "/" {
|
||||
// Restore trailing slash if it existed and path isn't just "/"
|
||||
u.Path += "/"
|
||||
}
|
||||
}
|
||||
|
||||
// Properly escape the path
|
||||
// First split on '/' to avoid escaping them
|
||||
parts := strings.Split(u.Path, "/")
|
||||
for i, part := range parts {
|
||||
parts[i] = url.PathEscape(part)
|
||||
}
|
||||
u.Path = strings.Join(parts, "/")
|
||||
|
||||
// Remove trailing fragment if empty
|
||||
if u.Fragment == "" {
|
||||
u.Fragment = ""
|
||||
}
|
||||
|
||||
// Remove trailing query if empty
|
||||
if u.RawQuery == "" {
|
||||
u.RawQuery = ""
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func EscapeURL(input string) string {
|
||||
// Only escape if not already escaped
|
||||
if strings.Contains(input, "%") && !strings.Contains(input, "% ") {
|
||||
return input
|
||||
}
|
||||
// Split URL into parts (protocol, host, p)
|
||||
parts := strings.SplitN(input, "://", 2)
|
||||
if len(parts) != 2 {
|
||||
return input
|
||||
}
|
||||
|
||||
protocol := parts[0]
|
||||
remainder := parts[1]
|
||||
|
||||
// If URL ends with just a slash, return as is
|
||||
if strings.HasSuffix(remainder, "/") && !strings.Contains(remainder[:len(remainder)-1], "/") {
|
||||
return input
|
||||
}
|
||||
|
||||
// Split host and p
|
||||
parts = strings.SplitN(remainder, "/", 2)
|
||||
host := parts[0]
|
||||
if len(parts) == 1 {
|
||||
return protocol + "://" + host
|
||||
}
|
||||
|
||||
// Escape the path portion
|
||||
escapedPath := url.PathEscape(parts[1])
|
||||
|
||||
// Reconstruct the URL
|
||||
return protocol + "://" + host + "/" + escapedPath
|
||||
}
|
||||
|
||||
// normalizePath trims trailing slash and handles empty path
|
||||
func TrimTrailingPathSlash(path string) string {
|
||||
// Handle empty path (e.g., "http://example.com" -> treat as root)
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
|
||||
// Trim trailing slash while preserving root slash
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
if path == "" { // This happens if path was just "/"
|
||||
return "/"
|
||||
}
|
||||
return path
|
||||
}
|
||||
384
gemini/url_test.go
Normal file
384
gemini/url_test.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "gemini://caolan.uk/cgi-bin/weather.py/wxfcs/3162"
|
||||
parsed, err := ParseURL(input, "", true)
|
||||
value, _ := parsed.Value()
|
||||
if err != nil || !(value == "gemini://caolan.uk:1965/cgi-bin/weather.py/wxfcs/3162") {
|
||||
t.Errorf("fail: %s", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAbsoluteURL_abs_url_input(t *testing.T) {
|
||||
t.Parallel()
|
||||
currentURL := URL{
|
||||
Protocol: "gemini",
|
||||
Hostname: "smol.gr",
|
||||
Port: 1965,
|
||||
Path: "/a/b",
|
||||
Descr: "Nothing",
|
||||
Full: "gemini://smol.gr:1965/a/b",
|
||||
}
|
||||
input := "gemini://a.b/c"
|
||||
output, err := DeriveAbsoluteURL(currentURL, input)
|
||||
if err != nil {
|
||||
t.Errorf("fail: %v", err)
|
||||
}
|
||||
expected := &URL{
|
||||
Protocol: "gemini",
|
||||
Hostname: "a.b",
|
||||
Port: 1965,
|
||||
Path: "/c",
|
||||
Descr: "",
|
||||
Full: "gemini://a.b:1965/c",
|
||||
}
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAbsoluteURL_abs_path_input(t *testing.T) {
|
||||
t.Parallel()
|
||||
currentURL := URL{
|
||||
Protocol: "gemini",
|
||||
Hostname: "smol.gr",
|
||||
Port: 1965,
|
||||
Path: "/a/b",
|
||||
Descr: "Nothing",
|
||||
Full: "gemini://smol.gr:1965/a/b",
|
||||
}
|
||||
input := "/c"
|
||||
output, err := DeriveAbsoluteURL(currentURL, input)
|
||||
if err != nil {
|
||||
t.Errorf("fail: %v", err)
|
||||
}
|
||||
expected := &URL{
|
||||
Protocol: "gemini",
|
||||
Hostname: "smol.gr",
|
||||
Port: 1965,
|
||||
Path: "/c",
|
||||
Descr: "",
|
||||
Full: "gemini://smol.gr:1965/c",
|
||||
}
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAbsoluteURL_rel_path_input(t *testing.T) {
|
||||
t.Parallel()
|
||||
currentURL := URL{
|
||||
Protocol: "gemini",
|
||||
Hostname: "smol.gr",
|
||||
Port: 1965,
|
||||
Path: "/a/b",
|
||||
Descr: "Nothing",
|
||||
Full: "gemini://smol.gr:1965/a/b",
|
||||
}
|
||||
input := "c/d"
|
||||
output, err := DeriveAbsoluteURL(currentURL, input)
|
||||
if err != nil {
|
||||
t.Errorf("fail: %v", err)
|
||||
}
|
||||
expected := &URL{
|
||||
Protocol: "gemini",
|
||||
Hostname: "smol.gr",
|
||||
Port: 1965,
|
||||
Path: "/a/b/c/d",
|
||||
Descr: "",
|
||||
Full: "gemini://smol.gr:1965/a/b/c/d",
|
||||
}
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeURLSlash(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "gemini://uscoffings.net/retro-computing/magazines/"
|
||||
normalized, _ := NormalizeURL(input)
|
||||
output := normalized.String()
|
||||
expected := input
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeURLNoSlash(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "gemini://uscoffings.net/retro-computing/magazines"
|
||||
normalized, _ := NormalizeURL(input)
|
||||
output := normalized.String()
|
||||
expected := input
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMultiSlash(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "gemini://uscoffings.net/retro-computing/////////a///magazines"
|
||||
normalized, _ := NormalizeURL(input)
|
||||
output := normalized.String()
|
||||
expected := "gemini://uscoffings.net/retro-computing/a/magazines"
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTrailingSlash(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "gemini://uscoffings.net/"
|
||||
normalized, _ := NormalizeURL(input)
|
||||
output := normalized.String()
|
||||
expected := "gemini://uscoffings.net/"
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeNoTrailingSlash(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "gemini://uscoffings.net"
|
||||
normalized, _ := NormalizeURL(input)
|
||||
output := normalized.String()
|
||||
expected := "gemini://uscoffings.net"
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTrailingSlashPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "gemini://uscoffings.net/a/"
|
||||
normalized, _ := NormalizeURL(input)
|
||||
output := normalized.String()
|
||||
expected := "gemini://uscoffings.net/a/"
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeNoTrailingSlashPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "gemini://uscoffings.net/a"
|
||||
normalized, _ := NormalizeURL(input)
|
||||
output := normalized.String()
|
||||
expected := "gemini://uscoffings.net/a"
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDot(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "gemini://uscoffings.net/retro-computing/./././////a///magazines"
|
||||
normalized, _ := NormalizeURL(input)
|
||||
output := normalized.String()
|
||||
expected := "gemini://uscoffings.net/retro-computing/a/magazines"
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePort(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "gemini://uscoffings.net:1965/a"
|
||||
normalized, _ := NormalizeURL(input)
|
||||
output := normalized.String()
|
||||
expected := "gemini://uscoffings.net/a"
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "gemini://chat.gemini.lehmann.cx:11965/"
|
||||
normalized, _ := NormalizeURL(input)
|
||||
output := normalized.String()
|
||||
expected := "gemini://chat.gemini.lehmann.cx:11965/"
|
||||
pass := reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
|
||||
input = "gemini://chat.gemini.lehmann.cx:11965/index?a=1&b=c"
|
||||
normalized, _ = NormalizeURL(input)
|
||||
output = normalized.String()
|
||||
expected = "gemini://chat.gemini.lehmann.cx:11965/index?a=1&b=c"
|
||||
pass = reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
|
||||
input = "gemini://chat.gemini.lehmann.cx:11965/index#1"
|
||||
normalized, _ = NormalizeURL(input)
|
||||
output = normalized.String()
|
||||
expected = "gemini://chat.gemini.lehmann.cx:11965/index#1"
|
||||
pass = reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
|
||||
input = "gemini://gemi.dev/cgi-bin/xkcd.cgi?1494"
|
||||
normalized, _ = NormalizeURL(input)
|
||||
output = normalized.String()
|
||||
expected = "gemini://gemi.dev/cgi-bin/xkcd.cgi?1494"
|
||||
pass = reflect.DeepEqual(output, expected)
|
||||
if !pass {
|
||||
t.Errorf("fail: %#v != %#v", output, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input string // URL string to parse
|
||||
expected string // Expected normalized path
|
||||
}{
|
||||
// Basic cases
|
||||
{
|
||||
name: "empty_path",
|
||||
input: "http://example.com",
|
||||
expected: "/",
|
||||
},
|
||||
{
|
||||
name: "root_path",
|
||||
input: "http://example.com/",
|
||||
expected: "/",
|
||||
},
|
||||
{
|
||||
name: "single_trailing_slash",
|
||||
input: "http://example.com/test/",
|
||||
expected: "/test",
|
||||
},
|
||||
{
|
||||
name: "no_trailing_slash",
|
||||
input: "http://example.com/test",
|
||||
expected: "/test",
|
||||
},
|
||||
|
||||
// Edge cases with slashes
|
||||
{
|
||||
name: "multiple_trailing_slashes",
|
||||
input: "http://example.com/test//",
|
||||
expected: "/test/",
|
||||
},
|
||||
{
|
||||
name: "multiple_consecutive_slashes",
|
||||
input: "http://example.com//test//",
|
||||
expected: "//test/",
|
||||
},
|
||||
{
|
||||
name: "only_slashes",
|
||||
input: "http://example.com////",
|
||||
expected: "///",
|
||||
},
|
||||
{
|
||||
name: "single_slash",
|
||||
input: "/",
|
||||
expected: "/",
|
||||
},
|
||||
|
||||
// Encoded characters
|
||||
{
|
||||
name: "encoded_spaces",
|
||||
input: "http://example.com/foo%20bar/",
|
||||
expected: "/foo%20bar",
|
||||
},
|
||||
{
|
||||
name: "encoded_special_chars",
|
||||
input: "http://example.com/foo%2Fbar/",
|
||||
expected: "/foo%2Fbar",
|
||||
},
|
||||
|
||||
// Query parameters and fragments
|
||||
{
|
||||
name: "with_query_parameters",
|
||||
input: "http://example.com/path?query=param",
|
||||
expected: "/path",
|
||||
},
|
||||
{
|
||||
name: "with_fragment",
|
||||
input: "http://example.com/path#fragment",
|
||||
expected: "/path",
|
||||
},
|
||||
{
|
||||
name: "with_both_query_and_fragment",
|
||||
input: "http://example.com/path?query=param#fragment",
|
||||
expected: "/path",
|
||||
},
|
||||
|
||||
// Relative URLs
|
||||
{
|
||||
name: "relative_path",
|
||||
input: "/just/a/path/",
|
||||
expected: "/just/a/path",
|
||||
},
|
||||
|
||||
// Unicode paths
|
||||
{
|
||||
name: "unicode_characters",
|
||||
input: "http://example.com/über/path/",
|
||||
expected: "/%C3%BCber/path",
|
||||
},
|
||||
{
|
||||
name: "unicode_encoded",
|
||||
input: "http://example.com/%C3%BCber/path/",
|
||||
expected: "/%C3%BCber/path",
|
||||
},
|
||||
|
||||
// Weird but valid cases
|
||||
{
|
||||
name: "dot_in_path",
|
||||
input: "http://example.com/./path/",
|
||||
expected: "/./path",
|
||||
},
|
||||
{
|
||||
name: "double_dot_in_path",
|
||||
input: "http://example.com/../path/",
|
||||
expected: "/../path",
|
||||
},
|
||||
{
|
||||
name: "mixed_case",
|
||||
input: "http://example.com/PaTh/",
|
||||
expected: "/PaTh",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u, err := url.Parse(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse URL %q: %v", tt.input, err)
|
||||
}
|
||||
|
||||
result := TrimTrailingPathSlash(u.EscapedPath())
|
||||
if result != tt.expected {
|
||||
t.Errorf("Input: %s\nExpected: %q\nGot: %q",
|
||||
u.Path, tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user