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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The following emojis are used to highlight certain changes:
### Added

- `gateway`: `GET`/`HEAD /ipfs/bafkqaaa?format=raw` now always returns `200` with an empty body, so probing clients keep marking the gateway as functional even when its backend cannot serve identity CIDs. `bitswap/network/httpnet` sends this [trustless gateway probe](https://specs.ipfs.tech/http-gateways/trustless-gateway/#dedicated-probe-paths) to check providers, and a failed probe drops the provider. Exported as `gateway.EmptyIdentityCID`. [#1179](https://github.com/ipfs/boxo/pull/1179)
- `path`: added `NewPathFromURI`, which accepts native IPFS URIs (`ipfs://cid`, `ipns://name`, `ipld://cid`, and the schemeless `ipfs:`/`ipns:`/`ipld:` forms) and rewrites them to canonical content paths, so values copied from browsers and other tools parse as-is. `NewPath` stays strict and still rejects URI-shaped input, leaving untrusted parsing such as DNSLink records unchanged. [#1182](https://github.com/ipfs/boxo/pull/1182)

### Changed

Expand Down
4 changes: 4 additions & 0 deletions path/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ func FromCid(cid cid.Cid) ImmutablePath {
// The given string is cleaned through [gopath.Clean], but preserving the final
// trailing slash. This function returns an error when the given string is not
// a valid content path.
//
// NewPath is strict: it accepts only canonical content paths (/ipfs, /ipns,
// /ipld). To also accept native IPFS URIs such as ipfs://{cid}, use
// [NewPathFromURI].
func NewPath(str string) (Path, error) {
segments := StringToSegments(str)

Expand Down
57 changes: 57 additions & 0 deletions path/uri.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package path

import "strings"

// NewPathFromURI is like [NewPath] but also accepts a native IPFS URI, rewriting
// it to the equivalent canonical content path before parsing:
//
// ipfs://{cid}/sub -> /ipfs/{cid}/sub
// ipns://{name}/sub -> /ipns/{name}/sub
// ipld://{cid}/sub -> /ipld/{cid}/sub
//
// The schemeless ipfs:{cid}, ipns:{name}, and ipld:{cid} forms are accepted too.
// The scheme is matched case-insensitively over ASCII, as URI schemes are
// case-insensitive (RFC 3986). Everything after the scheme is preserved
// byte-for-byte, so a case-sensitive CIDv0 root or a DNSLink name is not altered.
// A string that is already a content path, or is not an IPFS URI, is handed to
// [NewPath] unchanged.
//
// Use this only at input boundaries where values may be copied from a browser or
// another tool. [NewPath] stays strict, so contexts that must accept canonical
// content paths only (such as DNSLink records) are not loosened by this helper.
func NewPathFromURI(str string) (Path, error) {
return NewPath(normalizeURIScheme(str))
}

// normalizeURIScheme rewrites a native IPFS URI into a canonical content path.
// A string that is not an IPFS URI is returned unchanged. See [NewPathFromURI].
func normalizeURIScheme(str string) string {
for _, ns := range [...]string{IPFSNamespace, IPNSNamespace, IPLDNamespace} {
if hasURIScheme(str, ns) {
rest := strings.TrimPrefix(str[len(ns)+1:], "//") // drop the optional "//" authority separator
return "/" + ns + "/" + rest
}
}
return str
}

// hasURIScheme reports whether str begins with the scheme ns followed by ":"
// (e.g. "ipfs:"). ns is matched case-insensitively over ASCII.
func hasURIScheme(str, ns string) bool {
if len(str) <= len(ns) || str[len(ns)] != ':' {
return false
}
for i := range len(ns) {
if toLowerASCII(str[i]) != ns[i] {
return false
}
}
return true
}

func toLowerASCII(b byte) byte {
if b >= 'A' && b <= 'Z' {
return b + ('a' - 'A')
}
return b
}
118 changes: 118 additions & 0 deletions path/uri_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package path

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestNewPathFromURI(t *testing.T) {
t.Parallel()

t.Run("Valid URIs", func(t *testing.T) {
t.Parallel()

testCases := []struct {
src string
canonical string
namespace string
mutable bool
}{
// ipfs:// with CIDv1, with and without a sub-path
{"ipfs://bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", "/ipfs/bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", IPFSNamespace, false},
{"ipfs://bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku/a/b", "/ipfs/bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku/a/b", IPFSNamespace, false},

// CIDv0 is base58 and case-sensitive: the root must survive untouched.
{"ipfs://QmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n", "/ipfs/QmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n", IPFSNamespace, false},
{"ipfs://QmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n/a", "/ipfs/QmdfTbBqBPQ7VNxZEYEj14VmRuZBkqFbiwReogJgS1zR1n/a", IPFSNamespace, false},

// ipns:// with an IPNS key and with a DNSLink name
{"ipns://bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", "/ipns/bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", IPNSNamespace, true},
{"ipns://docs.ipfs.tech/concepts", "/ipns/docs.ipfs.tech/concepts", IPNSNamespace, true},

// ipld:// maps to the /ipld namespace
{"ipld://bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku/a", "/ipld/bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku/a", IPLDNamespace, false},

// Schemeless forms: ipfs:{cid}, ipns:{name}, ipld:{cid}
{"ipfs:bafkqaaa", "/ipfs/bafkqaaa", IPFSNamespace, false},
{"ipns:docs.ipfs.tech", "/ipns/docs.ipfs.tech", IPNSNamespace, true},
{"ipld:bafkqaaa", "/ipld/bafkqaaa", IPLDNamespace, false},

// Scheme is case-insensitive (RFC 3986); the rest is preserved.
{"IPFS://bafkqaaa", "/ipfs/bafkqaaa", IPFSNamespace, false},
{"IpNs://docs.ipfs.tech", "/ipns/docs.ipfs.tech", IPNSNamespace, true},

// Trailing slash is preserved, mirroring /ipfs/cid/ behaviour.
{"ipfs://bafkqaaa/", "/ipfs/bafkqaaa/", IPFSNamespace, false},

// Cleaning still applies after normalization.
{"ipfs://bafkqaaa/a/b/../c", "/ipfs/bafkqaaa/a/c", IPFSNamespace, false},

// A canonical path passes straight through to NewPath.
{"/ipfs/bafkqaaa", "/ipfs/bafkqaaa", IPFSNamespace, false},
{"/ipns/docs.ipfs.tech", "/ipns/docs.ipfs.tech", IPNSNamespace, true},
}

for _, testCase := range testCases {
p, err := NewPathFromURI(testCase.src)
assert.NoErrorf(t, err, "input %q", testCase.src)
if err != nil {
continue
}
assert.Equalf(t, testCase.canonical, p.String(), "input %q", testCase.src)
assert.Equalf(t, testCase.namespace, p.Namespace(), "input %q", testCase.src)
assert.Equalf(t, testCase.mutable, p.Mutable(), "input %q", testCase.src)
}
})

t.Run("Invalid URIs still error", func(t *testing.T) {
t.Parallel()

// Empty-after-scheme reduces to "/ipfs/" etc., which NewPath rejects.
for _, src := range []string{"ipfs:", "ipns:", "ipld:", "ipfs://", "ipns://", "IPFS://"} {
_, err := NewPathFromURI(src)
assert.ErrorIsf(t, err, ErrInsufficientComponents, "input %q", src)
assert.ErrorIsf(t, err, &ErrInvalidPath{}, "input %q always an ErrInvalidPath", src)
}

// A malformed CID still fails (as an ErrInvalidPath wrapping the CID error).
_, err := NewPathFromURI("ipfs://notacid")
assert.ErrorIs(t, err, &ErrInvalidPath{})
})

t.Run("Returns ImmutablePath for ipfs:// URIs", func(t *testing.T) {
t.Parallel()

p, err := NewPathFromURI("ipfs://bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku/a")
assert.NoError(t, err)
assert.IsType(t, ImmutablePath{}, p)
})

// A literal value that merely starts with "ipns"/"ipfs" but has no ":" right
// after the namespace must not be rewritten.
t.Run("Does not misfire on non-URI input", func(t *testing.T) {
t.Parallel()

for _, src := range []string{"ipfsfile", "ipns-notes.txt", "ipfsbar:baz", "ipns", "/ipfs/bafkqaaa"} {
assert.Equalf(t, src, normalizeURIScheme(src), "input %q must be unchanged", src)
}
})
}

// TestNewPathStaysStrict guards the security boundary: NewPath itself must keep
// rejecting native IPFS URIs, so contexts that parse untrusted strings (such as
// DNSLink records) are not loosened. Only NewPathFromURI accepts URIs.
func TestNewPathStaysStrict(t *testing.T) {
t.Parallel()

for _, src := range []string{
"ipfs://bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku",
"ipns://attacker.example",
"ipfs:bafkqaaa",
"ipns:attacker.example",
} {
_, err := NewPath(src)
assert.Errorf(t, err, "NewPath must reject URI %q", src)
assert.ErrorIsf(t, err, &ErrInvalidPath{}, "input %q", src)
}
}
Loading