Skip to content

Return None from make_relative when the target cannot be a base - #1151

Open
youdie006 wants to merge 1 commit into
servo:mainfrom
youdie006:fix/make-relative-cannot-be-a-base-target
Open

Return None from make_relative when the target cannot be a base#1151
youdie006 wants to merge 1 commit into
servo:mainfrom
youdie006:fix/make-relative-cannot-be-a-base-target

Conversation

@youdie006

Copy link
Copy Markdown

The problem

make_relative checks self.cannot_be_a_base() but never the argument, so a cannot-be-a-base target with a matching scheme gets past the guard and comes back as a relative reference that does not resolve to it:

let base = Url::parse("foo:/a/b")?;
let target = Url::parse("foo:bar")?;

base.make_relative(&target);   // Some("../ar")   <- the 'b' is gone
base.join("../ar")?;           // foo:/ar, not foo:bar

Measured across a few shapes, with two controls at the bottom:

base target cannot_be_a_base(target) make_relative join back inverse holds
foo:/a/b foo:bar true Some("../ar") foo:/ar no
foo:/a/b foo: true Some("../") foo:/ no
foo:/a/b foo:a/b true Some("a/b") foo:/a/a/b no
foo:/a/b foo:?q true Some("../?q") foo:/?q no
foo:/a/b foo:/a/c false Some("c") foo:/a/c yes
mailto:a@… mailto:b@… true None

Each of the first four reports success and returns a value that breaks the relation the docs open with:

Creates a relative URL if possible, with this URL as the base URL.
This is the inverse of [join].
url/src/lib.rs:474-476

The caller has no way to tell: there is no error, just a Some whose contents are wrong.

Cause

url/src/lib.rs:531-539:

fn extract_path_filename(s: &str) -> (&str, &str) {
    let last_slash_idx = s.rfind('/').unwrap_or(0);
    let (path, filename) = s.split_at(last_slash_idx);
    if filename.is_empty() { (path, "") } else { (path, &filename[1..]) }
}

&filename[1..] strips what it assumes is a leading /. For an opaque path rfind('/') returns None, unwrap_or(0) makes filename the whole string, and the slice eats a real character. That assumption holds only for base-able paths — true of self, which is guarded at url/src/lib.rs:516, and never checked for the argument.

The change

Extend the existing guard. A cannot-be-a-base URL has an opaque path, so no relative reference resolved against any base can produce it, and None is what the documented contract already calls for:

Errors

If this URL can't be a base for the given URL, None is returned.
url/src/lib.rs:509-511

I kept it to the guard rather than reworking extract_path_filename, since the guard alone matches the contract exactly and leaves the base-able path handling untouched.

Why the tests miss it

url/tests/unit.rs:1145 test_make_relative uses http, https, file and ftp throughout — special schemes, which can never be cannot-be-a-base, so the branch is unreachable from that corpus. error_tests has one non-special entry, ("mailto:a@example.com", "mailto:b@example.com"), and the self guard catches that one. The asymmetric case — base is a base, target is not — had no coverage at all.

Four cases added to error_tests.

Verification

Reverting only url/src/lib.rs and keeping the tests fails with left: Some("../ar"), right: None.

cargo test --workspace green, including the WPT urltestdata runs. cargo fmt --check and cargo clippy -p url --all-targets clean.

Related

#772 reports that make_relative strips a leading / for http://…//bar. That is a different path through the same helper — the http case reaches extract_path_filename with a base-able path — and this change does not address it. I kept the scope to the contract violation so the two can be judged separately.


Disclosure: this patch was prepared with AI assistance. The reproduction, the red/green check and the suite runs above were executed against this branch; happy to adjust anything on request.

make_relative guards on self.cannot_be_a_base() but never on the
argument, so a cannot-be-a-base target with a matching scheme gets past
it and comes back as a relative reference that does not resolve to it:

    Url::parse("foo:/a/b")?.make_relative(&Url::parse("foo:bar")?)
    // Some("../ar")   -- the 'b' is gone

    base.join("../ar")  // foo:/ar, not foo:bar

Also Some("../") for foo:, Some("a/b") for foo:a/b, Some("../?q") for
foo:?q. Each reports success and returns a value that breaks the
inverse-of-join relation the docs open with.

The cause is extract_path_filename: rfind('/') returns None for an opaque
path, unwrap_or(0) then makes the whole string the filename, and
&filename[1..] strips what it assumes is a leading slash but is a real
character. That only holds for base-able paths - true of self, which is
guarded, and never checked for the argument.

A cannot-be-a-base URL has an opaque path, so no relative reference
resolved against any base can produce it. Returning None matches the
documented contract: "If this URL can't be a base for the given URL,
None is returned."

The existing cases in error_tests are all special schemes, which can
never be cannot-be-a-base, plus one mailto pair that the self guard
already caught. The asymmetric case had no coverage.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant