Repository: sphinx-doc/sphinx

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..)



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,
+        )