| Internet-Draft | A Deterministic Algorithm for Resolving | November 2025 |
| Davidson | Expires 6 May 2026 | [Page] |
This document specifies a deterministic algorithm for resolving shortlinks (compact string identifiers) into fully-qualified URLs. The algorithm supports both absolute URL destinations and origin-relative internal redirects with loop protection. It defines deterministic precedence rules for combining query parameters and fragments from multiple sources during resolution, enabling consistent behavior across clients, servers, and command-line tools.¶
This note is to be removed before publishing as an RFC.¶
Source for this draft and an issue tracker can be found at https://github.com/EthanThatOneKid/go-protocol.¶
This Internet-Draft is submitted in full conformance with the provisions of BCP 78 and BCP 79.¶
Internet-Drafts are working documents of the Internet Engineering Task Force (IETF). Note that other groups may also distribute working documents as Internet-Drafts. The list of current Internet-Drafts is at https://datatracker.ietf.org/drafts/current/.¶
Internet-Drafts are draft documents valid for a maximum of six months and may be updated, replaced, or obsoleted by other documents at any time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress."¶
This Internet-Draft will expire on 6 May 2026.¶
Copyright (c) 2025 IETF Trust and the persons identified as the document authors. All rights reserved.¶
This document is subject to BCP 78 and the IETF Trust's Legal Provisions Relating to IETF Documents (https://trustee.ietf.org/license-info) in effect on the date of publication of this document. Please review these documents carefully, as they describe your rights and restrictions with respect to this document.¶
Shortlinks provide a mechanism for mapping compact, memorable identifiers to longer URLs. However, without a standardized resolution algorithm, different implementations may resolve the same shortlink to different destinations or handle edge cases inconsistently, leading to interoperability issues.¶
This document specifies a deterministic algorithm for resolving shortlinks that:¶
Supports longest-prefix key matching to enable hierarchical shortlink structures¶
Handles both absolute URL destinations and origin-relative internal redirects¶
Provides loop protection for internal redirect chains¶
Defines clear precedence rules for combining query parameters and fragments from multiple sources¶
Remains stateless and deterministic for consistent behavior across implementations¶
The algorithm is designed to work with any ruleset source (database, file, configuration, etc.) and requires no protocol-level changes or IANA registrations.¶
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.¶
The following terms are used throughout this document:¶
Shortlink: A compact string identifier that maps to a URL destination.¶
Ruleset: A collection of key-value pairs where keys are shortlink identifiers (non-empty strings without leading "/") and values are either absolute URLs (strings whose first four characters are "http") or origin-relative paths (strings whose first character is "/").¶
Request URL: The incoming URL to be resolved, which may contain an embedded shortlink in its pathname component.¶
Destination URL: The fully-qualified URL that results from resolving a shortlink.¶
Internal Redirect: A shortlink value that is an origin-relative path (whose first character is "/"), which requires further resolution within the same origin.¶
Absolute Destination: A shortlink value that is a fully-qualified URL (whose first four characters are "http", including both "http" and "https" schemes), which serves as the final destination.¶
Longest-Prefix Match: The process of finding the matching shortlink key by starting with the full pathname (minus leading "/") and progressively removing the trailing path segment (everything from the last "/" character onward, inclusive of the "/") until either a matching key is found in the Ruleset or no "/" character remains in the candidate key.¶
The algorithm takes as input:¶
The algorithm produces as output:¶
A Destination URL (the resolved fully-qualified URL)¶
The algorithm operates in three main phases:¶
From the Request URL, extract and preserve the following components:¶
Origin: The scheme, host, and port components of the Request URL¶
Request Query: The search component of the Request URL (including the leading "?" if present, otherwise an empty string)¶
Request Fragment: The hash component of the Request URL (including the leading "#" if present, otherwise an empty string)¶
Pathname: The pathname component of the Request URL (the path portion, including the leading "/")¶
These preserved components will be used during final URL construction according to the precedence rules defined below. Initialize the following variables:¶
The resolution process begins with the pathname from the Request URL and searches for a matching shortlink key using longest-prefix matching.¶
At the start of each resolution iteration, find a matching shortlink key using the following procedure:¶
Let candidate = pathname.slice(1) (the pathname with the leading "/"
character removed). If pathname is exactly "/", then candidate = "" (empty
string).¶
Check if candidate exists as a key in the Ruleset:¶
While candidate.length > 0: a. Find the last occurrence of "/" in
candidate using candidate.lastIndexOf("/") b. If no "/" is found (result
is -1), exit the loop, no match found c. Set candidate =
candidate.slice(0, slashIndex) (everything before the last "/", exclusive)
d. Check if candidate exists as a key in the Ruleset:¶
If no matching key is found after this process completes, the algorithm terminates and returns the Request URL with any accumulated relativePathname appended (see "No Match Found" below).¶
At the start of each resolution iteration (before longest-prefix matching), if the pathname contains "#" or "?" characters, these MUST be extracted and removed from the pathname. The extraction MUST occur in the following order:¶
Hash Extraction (performed first):¶
Query Extraction (performed after hash extraction):¶
After extraction, the pathname contains only path segments and is used for
longest-prefix matching. The extracted hash and query are preserved in
initialHash and initialQuery (only the first extraction of each is preserved
across iterations).¶
When a matching key matchedKey is found in the Ruleset:¶
Let value = the value associated with matchedKey in the Ruleset¶
Calculate the remaining pathname segment:¶
After removing the leading "/" from pathname and matching against
matchedKey, the remaining segment is calculated as: remainingPath =
pathname.slice(matchedKey.length + 1) where matchedKey.length + 1
accounts for the matched key and the "/" that follows it (or would follow
it). This extracts everything from index matchedKey.length + 1 to the end
of the pathname string.¶
If pathname.length <= matchedKey.length + 1, then remainingPath = ""
(empty string), meaning the pathname ended exactly at or before the end of
the matched key.¶
Prepend remainingPath to relativePathname: relativePathname =
remainingPath + relativePathname (string concatenation, preserving any
leading "/" in remainingPath)¶
Determine the value type:¶
Absolute Destination (first four characters of value are "http"):
- Parse value as a URL to obtain destination URL object
- Resolution terminates immediately
- Construct final URL (see "Construction Phase" below)¶
Internal Redirect (first character of value is "/"):
- Set pathname = value (the shortlink value becomes the new pathname)
- Increment redirectCount by 1
- If redirectCount >= 256, terminate with error "too many internal
redirects"
- Continue to next resolution iteration (return to "Handling Hash and Query
in Pathname" at the start of the iteration)¶
If no matching key is found during longest-prefix matching:¶
After resolution completes (either by finding an absolute destination or no match), construct the final Destination URL using the following procedure:¶
The base URL components are determined as follows:¶
Origin:¶
Pathname:¶
If an absolute destination was found: start with the destination URL's pathname component (without leading/trailing modification)¶
Otherwise: use the pathname from the constructed URL in "No Match Found"¶
In both cases: append relativePathname directly (string concatenation)
without any separator, as relativePathname already includes any necessary
leading "/"¶
Query parameters MUST be combined using the following procedure and precedence order (higher precedence overwrites lower precedence for matching parameter names):¶
Base query: Start with an empty URLSearchParams object¶
Add destination query (lowest precedence): If an absolute destination was
found, parse destination.search (the destination URL's search component,
including leading "?" if present) as URLSearchParams and add all parameters
to the base¶
Add pathname-embedded query (medium precedence): If initialQuery is not
empty, parse it (including leading "?" if present) as URLSearchParams and for
each key-value pair, call URLSearchParams.set(key, value) on the base (this
overwrites any existing parameters with the same key)¶
Add Request URL query (highest precedence): If requestQuery is not
empty, parse it (including leading "?" if present) as URLSearchParams and for
each key-value pair, call URLSearchParams.set(key, value) on the base (this
overwrites any existing parameters with the same key)¶
Convert the final URLSearchParams to a string using
URLSearchParams.toString(). If the result is non-empty, prefix it with "?" to
form the final query string. Otherwise, the query string is "" (empty string).¶
Fragment values MUST be selected using the following precedence (higher precedence replaces lower precedence):¶
Request URL fragment (highest precedence): If requestHash is not empty,
use requestHash (which includes the leading "#")¶
Pathname-embedded fragment (medium precedence): If requestHash is empty
and initialHash is not empty, use initialHash (which includes the leading
"#")¶
Destination URL fragment (lowest precedence): If both requestHash and
initialHash are empty and an absolute destination was found, use
destination.hash (which includes the leading "#" if present, otherwise "")¶
No fragment: If none of the above apply, use "" (empty string)¶
Unlike query parameters, fragments are not merged; only one fragment value is used based on the precedence order above.¶
Assemble the final Destination URL by concatenating the following components in order:¶
{origin}{destination-pathname}{relativePathname}{query-string}{fragment-string}
¶
Where:¶
{origin} is the scheme, host, and port (e.g., "https://example.com")¶
{destination-pathname} is the pathname component from the destination (e.g.,
"/path/to")¶
{relativePathname} is the accumulated relative pathname (e.g.,
"/extra/segments")¶
{query-string} is the combined query string from "Query Parameter
Combination" (e.g., "?foo=bar" or "")¶
{fragment-string} is the selected fragment from "Fragment Selection" (e.g.,
"#section" or "")¶
No additional separators are inserted between components beyond what is already present in the component values themselves.¶
The following pseudocode summarizes the algorithm:¶
``` function resolve(RequestURL, Ruleset): // Initialize variables origin = RequestURL.origin requestQuery = RequestURL.search // Includes "?" if present, else "" requestHash = RequestURL.hash // Includes "#" if present, else "" pathname = RequestURL.pathname // Always starts with "/"¶
relativePathname = "" initialQuery = "" initialHash = "" redirectCount = 0 matchedKey = null // Initialize to null/undefined¶
while redirectCount < 256: matchedKey = null // Reset for each iteration¶
// Step 1: Extract embedded hash from pathname (first)
hashIdx = pathname.lastIndexOf("#")
if hashIdx >= 0:
if initialHash == "":
initialHash = pathname.slice(hashIdx) // From "#" onward, inclusive
pathname = pathname.slice(0, hashIdx) // Before "#", exclusive
// Step 2: Extract embedded query from pathname (after hash)
queryIdx = pathname.lastIndexOf("?")
if queryIdx >= 0:
if initialQuery == "":
initialQuery = pathname.slice(queryIdx) // From "?" onward, inclusive
pathname = pathname.slice(0, queryIdx) // Before "?", exclusive
// Step 3: Longest-prefix match
if pathname == "/":
candidate = ""
else:
candidate = pathname.slice(1) // Remove leading "/"
// Try exact match first
if candidate != "" and Ruleset.hasOwnProperty(candidate):
matchedKey = candidate
else:
// Try progressively shorter prefixes
while candidate.length > 0:
slashIdx = candidate.lastIndexOf("/")
if slashIdx == -1:
break // No more "/" found, no match
candidate = candidate.slice(0, slashIdx) // Remove last segment
if Ruleset.hasOwnProperty(candidate):
matchedKey = candidate
break
// Step 4: Process match result
if matchedKey == null:
// No match found
destination = new URL(pathname + relativePathname, origin)
query = combineQueries("", initialQuery, requestQuery)
hash = selectFragment(requestHash, initialHash, "")
return destination.origin + destination.pathname + query + hash
// Match found, process it
value = Ruleset[matchedKey]
// Calculate remaining path
if pathname.length > matchedKey.length + 1:
remainingPath = pathname.slice(matchedKey.length + 1)
else:
remainingPath = ""
relativePathname = remainingPath + relativePathname
// Check value type
if value.slice(0, 4) == "http":
// Absolute destination
destination = new URL(value)
query = combineQueries(destination.search, initialQuery, requestQuery)
hash = selectFragment(requestHash, initialHash, destination.hash)
return destination.origin + destination.pathname +
relativePathname + query + hash
if value.length > 0 and value[0] == "/":
// Internal redirect
pathname = value
redirectCount++
continue // Next iteration
¶
// Loop limit exceeded throw Error("too many internal redirects")¶
function combineQueries(baseQuery, pathnameQuery, requestQuery): // baseQuery is lowest precedence (destination query) // pathnameQuery is medium precedence // requestQuery is highest precedence¶
params = new URLSearchParams(baseQuery)¶
// Add pathname-embedded query (overwrites base) if pathnameQuery != "": pathParams = new URLSearchParams(pathnameQuery) for each key-value pair (key, value) in pathParams: params.set(key, value)¶
// Add request query (overwrites base and pathname) if requestQuery != "": requestParams = new URLSearchParams(requestQuery) for each key-value pair (key, value) in requestParams: params.set(key, value)¶
result = params.toString() return result.length > 0 ? "?" + result : ""¶
function selectFragment(requestHash, initialHash, destinationHash): if requestHash != "": return requestHash if initialHash != "": return initialHash return destinationHash // May be "" if destination has no fragment ```¶
Ruleset:¶
json
{
"github": "https://github.com",
"repo": "https://github.com/FartLabs/go"
}
¶
Request: https://example.com/github/ietf/guidelines¶
Resolution:¶
Pathname "/github/ietf/guidelines" - remove leading "/" to get candidate "github/ietf/guidelines"¶
Check Ruleset for "github/ietf/guidelines" - not found¶
Find last "/" at index 6, candidate becomes "github"¶
Check Ruleset for "github" - found: value "https://github.com"¶
Calculate remainingPath: pathname is "/github/ietf/guidelines", matchedKey is "github" (length 6), so pathname.slice(matchedKey.length + 1) = pathname.slice(7) = "/ietf/guidelines" (from index 7, which is the "/" after "github")¶
relativePathname = "/ietf/guidelines" + "" = "/ietf/guidelines"¶
Value starts with "http" → absolute destination¶
Parse "https://github.com" as URL: origin = "https://github.com", pathname = "/"¶
Query: "" (no queries), Fragment: "" (no fragments)¶
Result: https://github.com + / + /ietf/guidelines + + =
https://github.com/ietf/guidelines¶
Ruleset:¶
json
{
"docs": "/documentation",
"docs/api": "/documentation/reference"
}
¶
Request: https://example.com/docs/api/v1/users¶
Resolution: Iteration 1:¶
Pathname "/docs/api/v1/users" - remove leading "/" to get candidate "docs/api/v1/users"¶
Check Ruleset for "docs/api/v1/users" - not found¶
Find last "/" at index 8, candidate becomes "docs/api"¶
Check Ruleset for "docs/api" - found: value "/documentation/reference"¶
Calculate remainingPath: pathname.slice(8 + 1) = pathname.slice(9) = "/v1/users"¶
relativePathname = "/v1/users" + "" = "/v1/users"¶
Value starts with "/" → internal redirect¶
pathname = "/documentation/reference", redirectCount = 1¶
Iteration 2:¶
Pathname "/documentation/reference" - remove leading "/" to get candidate "documentation/reference"¶
Check Ruleset for "documentation/reference" - not found¶
Find last "/" at index 13, candidate becomes "documentation"¶
Check Ruleset for "documentation" - not found¶
Find last "/" at index -1 (no "/" in "documentation") - no match found¶
Construct destination: new URL("/documentation/reference" + "/v1/users", origin)¶
Query: "" (no queries), Fragment: "" (no fragments)¶
Result: https://example.com/documentation/reference/v1/users¶
Ruleset:¶
json
{
"example": "https://example.com?baz=qux"
}
¶
Request: https://example.com/example?foo=bar¶
Resolution:¶
Pathname "/example" - remove leading "/" to get candidate "example"¶
Match found: key "example", value "https://example.com?baz=qux"¶
No remaining pathname (matchedKey.length + 1 = 8, pathname.length = 8, so remainingPath = "")¶
Value starts with "http" → absolute destination¶
Parse "https://example.com?baz=qux" as URL: destination.search = "?baz=qux"¶
Combine queries:¶
Fragment: requestHash = "" (no fragment in request), initialHash = "", destinationHash = ""¶
Result: https://example.com/?baz=qux&foo=bar¶
Ruleset:¶
json
{
"example": "https://example.com#yin"
}
¶
Request: https://example.com/example#yang¶
Resolution:¶
Pathname "/example" - remove leading "/" to get candidate "example"¶
Match found: key "example", value "https://example.com#yin"¶
No remaining pathname (matchedKey.length + 1 = 8, pathname.length = 8, so remainingPath = "")¶
Value starts with "http" → absolute destination¶
Parse "https://example.com#yin" as URL: destination.hash = "#yin"¶
Query: no query parameters present, result = ""¶
Fragment selection: requestHash = "#yang" (not empty) → use "#yang"¶
Result: https://example.com/#yang¶
Ruleset:¶
json
{
"c": "https://example.com/calendar"
}
¶
Request: https://example.com/colors¶
Resolution:¶
Pathname "/colors" - remove leading "/" to get candidate "colors"¶
Check Ruleset for "colors" - not found¶
Find last "/" in "colors" - not found (lastIndexOf returns -1)¶
No matching key found¶
Construct destination: new URL("/colors" + "", origin) = "https://example.com/colors"¶
Query: "" (no queries), Fragment: "" (no fragments)¶
Result: https://example.com/colors (unchanged)¶
The algorithm includes protection against infinite redirect loops by limiting internal redirects to 256 iterations. Implementations MUST enforce this limit and MUST reject requests that exceed it. However, 256 may be insufficient for legitimate deeply-nested redirect structures; implementers should consider logging or monitoring redirect counts to identify potentially problematic rulesets.¶
Implementations SHOULD validate that:¶
Request URLs are well-formed and use supported schemes¶
Ruleset values are valid URLs or paths¶
Pathname components do not contain illegal characters¶
Malformed input may cause unpredictable behavior or security vulnerabilities.¶
The security of the resolution algorithm depends on the trustworthiness of the ruleset source. Implementations SHOULD:¶
The resolution process may reveal information about the ruleset structure through timing differences or error messages. Implementations SHOULD consider:¶
When appending relative pathname segments to destination URLs, implementations MUST ensure that the resulting pathname is safe and does not allow path traversal attacks (e.g., "../" sequences). The algorithm as specified does not explicitly sanitize these segments, so implementations should validate or sanitize the final constructed URL before use.¶
The query parameter merging behavior means that user-supplied query parameters from the Request URL can overwrite destination URL parameters. This may be desired behavior, but implementers should be aware that it could be used to modify the intended destination behavior. Similarly, fragments from the Request URL take precedence, which may override intended anchor targets in the destination.¶
Even with loop protection, long redirect chains can be used for:¶
Denial of service through excessive processing¶
Obfuscation of final destination¶
Tracking or analytics purposes¶
Implementations should consider logging redirect chains and potentially limiting chain length more aggressively than the 256-iteration limit.¶
This document has no IANA actions.¶
This section records the status of known implementations of the protocol defined by this specification at the time of posting of this Internet-Draft.¶
A reference implementation of this algorithm is available as the @fartlabs/go
package for the Deno runtime, published on the JavaScript Registry (JSR). The
implementation demonstrates that the protocol is implementable and provides a
reference for developers implementing the algorithm in other languages or
runtimes.¶
The reference implementation can be found at:¶
JSR Package: https://jsr.io/@fartlabs/go¶
Source Code: https://github.com/FartLabs/go¶
This document was inspired by the need for consistent shortlink resolution across different implementations and platforms.¶