Repository: sphinx-doc/sphinx (Python)

Issue:
Double slashes when generating the inventory file URL
### Describe the bug

This might be considered an edge case but I've noticed that if the value tuple of a `intersphinx_mapping` contains a base URI with a trailing slash and `None` as the inventory file, Sphinx will try to load a URL with double slashes.

### How to Reproduce

Using Sphinx 8.0.2 set the following in the `conf.py` file of a `sphinx-quickstart` project:

```python
extensions = ["sphinx.ext.intersphinx"]
intersphinx_mapping = {'python': ('https://docs.python.org/3/', None)}
```

Run `make html` and notice the double slashes in the output message:

```console
...
loading intersphinx inventory 'python' from https://docs.python.org/3//objects.inv ...
...
```

The double slashes URL could be problematic depending on the web server setup.

### Environment Information

```text
Platform:              linux; (Linux-6.5.0-45-generic-x86_64-with-glibc2.35)
Python version:        3.10.12 (main, Jul 29 2024, 16:56:48) [GCC 11.4.0])
Python implementation: CPython
Sphinx version:        8.0.2
Docutils version:      0.21.2
Jinja2 version:        3.1.4
Pygments version:      2.18.0
```


### Sphinx extensions

```python
["sphinx.ext.intersphinx"]
```


### Additional context

I think the problem could have been introduced in https://github.com/sphinx-doc/sphinx/pull/12087 where:

https://github.com/sphinx-doc/sphinx/blob/f90b506a9ff2b202fbec707be776cfa590519a14/sphinx/ext/intersphinx/_load.py#L185-L186

was changed into:

https://github.com/sphinx-doc/sphinx/blob/fadb6b10cb15d2a8ce336cee307dcb3ff64680bd/sphinx/ext/intersphinx/_load.py#L214

`posixpath.join` removes the trailing slash in the target URI, but the f-string doesn't.


Discussion:
PR welcome!

A
Ah yes, this one is on me. I don't remember why I did this. Maybe I incorrectly thought that the target_uri was stripped of its slash and wanted to reduce the imports. If this is the case, then I'm sorry. I'll do something for that this w-e or someone can fix it (it shouldn't be that hard).
Would `removesuffix('/')` be sufficient to fix this, or should we reinstate `posixpath` joining?
Technically, since we are dealing with URLs, we should have used `rstrip('/')` because if you put 3 forward slashes, then 

```py
>>> posixpath.join('https://a.com//', 'b')
'https://a.com//b'
```

I'll prepare a PR.
Websites probably _shouldn't_ accept significant-multiple-separators in their paths, but I think that technically it's valid for them to do that. (serving different content at `foo.invalid/file.txt` from `foo.invalid/////file.txt`, for example).

But `foo.invalidobjects.env` is never what we want, so I wonder whether the logic here should in fact be `base + ('/' if not base.endswith('/') else '') + inv_filename`.
(and then it's a question of whether we do that ourselves or via `posixpath` -- as presented -- or some other mechanism.  it's not a _file_ path, so `posixpath` seems a bit odd to me, but in this use case, the relevant logic does probably overlap fairly closely..)
I revert it to the old behaviour in #12807. I don't want to mess with URLs and get complaints later :'). If we want to move from our current logic, more places need to be changed (there are some ifs where we test whether the URI is in some set of URIs where we explicitly add a forward slash, I don't remember where but I know it exists).



Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 3f0ce6e7ba0..5785d62ef95 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -83,6 +83,10 @@ Bugs fixed
   e.g., ``index.html#foo`` becomes ``#foo``.
   (note: continuation of a partial fix added in Sphinx 7.3.0)
   Patch by James Addison (with reference to prior work by Eric Norige)
+* #12782: intersphinx: fix double forward slashes when generating the inventory
+  file URL (user-defined base URL of an intersphinx project are left untouched
+  even if they end with double forward slashes).
+  Patch by Bénédikt Tran.
 
 Testing
 -------
diff --git a/sphinx/ext/intersphinx/_load.py b/sphinx/ext/intersphinx/_load.py
index 1973de379be..358ae1f7ccb 100644
--- a/sphinx/ext/intersphinx/_load.py
+++ b/sphinx/ext/intersphinx/_load.py
@@ -211,7 +211,10 @@ def _fetch_inventory_group(
 
     for location in project.locations:
         # location is either None or a non-empty string
-        inv = f'{project.target_uri}/{INVENTORY_FILENAME}' if location is None else location
+        if location is None:
+            inv = posixpath.join(project.target_uri, INVENTORY_FILENAME)
+        else:
+            inv = location
 
         # decide whether the inventory must be read: always read local
         # files; remote ones only if the cache time is expired


Test patch:
diff --git a/tests/test_extensions/test_ext_intersphinx.py b/tests/test_extensions/test_ext_intersphinx.py
index c41ac982ce5..99dde92a57f 100644
--- a/tests/test_extensions/test_ext_intersphinx.py
+++ b/tests/test_extensions/test_ext_intersphinx.py
@@ -12,6 +12,7 @@
 
 from sphinx import addnodes
 from sphinx.builders.html import INVENTORY_FILENAME
+from sphinx.config import Config
 from sphinx.errors import ConfigError
 from sphinx.ext.intersphinx import (
     inspect_main,
@@ -768,3 +769,73 @@ def test_intersphinx_cache_limit(app):
             config=app.config,
             srcdir=app.srcdir,
         )
+
+
+def test_intersphinx_fetch_inventory_group_url():
+    class InventoryHandler(http.server.BaseHTTPRequestHandler):
+        def do_GET(self):
+            self.send_response(200, 'OK')
+            self.end_headers()
+            self.wfile.write(INVENTORY_V2)
+
+        def log_message(*args, **kwargs):
+            # Silenced.
+            pass
+
+    with http_server(InventoryHandler) as server:
+        url1 = f'http://localhost:{server.server_port}'
+        url2 = f'http://localhost:{server.server_port}/'
+
+        config = Config()
+        config.intersphinx_cache_limit = -1
+        config.intersphinx_mapping = {
+            '1': (url1, None),
+            '2': (url2, None),
+        }
+
+        now = int(time.time())
+        # we can use 'srcdir=None' since we are raising in _fetch_inventory
+        kwds = {'cache': {}, 'now': now, 'config': config, 'srcdir': None}
+        # We need an exception with its 'args' attribute set (see error
+        # handling in sphinx.ext.intersphinx._load._fetch_inventory_group).
+        side_effect = ValueError('')
+
+        project1 = _IntersphinxProject(
+            name='1', target_uri=url1, locations=(url1, None)
+        )
+        with mock.patch(
+            'sphinx.ext.intersphinx._load._fetch_inventory', side_effect=side_effect
+        ) as mockfn:
+            assert not _fetch_inventory_group(project=project1, **kwds)
+        mockfn.assert_any_call(
+            target_uri=url1,
+            inv_location=url1,
+            config=config,
+            srcdir=None,
+        )
+        mockfn.assert_any_call(
+            target_uri=url1,
+            inv_location=url1 + '/' + INVENTORY_FILENAME,
+            config=config,
+            srcdir=None,
+        )
+
+        project2 = _IntersphinxProject(
+            name='2', target_uri=url2, locations=(url2, None)
+        )
+        with mock.patch(
+            'sphinx.ext.intersphinx._load._fetch_inventory', side_effect=side_effect
+        ) as mockfn:
+            assert not _fetch_inventory_group(project=project2, **kwds)
+        mockfn.assert_any_call(
+            target_uri=url2,
+            inv_location=url2,
+            config=config,
+            srcdir=None,
+        )
+        mockfn.assert_any_call(
+            target_uri=url2,
+            inv_location=url2 + INVENTORY_FILENAME,
+            config=config,
+            srcdir=None,
+        )