FossilRepo

Fix xfer: handle HTTP Basic Auth for fossil push/pull/clone

ragelink 2026-04-07 21:34 trunk
Commit c2dd86c08bde705f4f02fdfaa46100e15d07794baec85c10b04e2b7462c4c4fc
--- accounts/__pycache__/views.cpython-314.pyc
+++ accounts/__pycache__/views.cpython-314.pyc
cannot compute difference between binary files
11
--- accounts/__pycache__/views.cpython-314.pyc
+++ accounts/__pycache__/views.cpython-314.pyc
0 annot compute difference between binary files
1
--- accounts/__pycache__/views.cpython-314.pyc
+++ accounts/__pycache__/views.cpython-314.pyc
0 annot compute difference between binary files
1
--- config/__pycache__/settings.cpython-314.pyc
+++ config/__pycache__/settings.cpython-314.pyc
cannot compute difference between binary files
11
--- config/__pycache__/settings.cpython-314.pyc
+++ config/__pycache__/settings.cpython-314.pyc
0 annot compute difference between binary files
1
--- config/__pycache__/settings.cpython-314.pyc
+++ config/__pycache__/settings.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/api_auth.cpython-314.pyc
+++ fossil/__pycache__/api_auth.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/api_auth.cpython-314.pyc
+++ fossil/__pycache__/api_auth.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/api_auth.cpython-314.pyc
+++ fossil/__pycache__/api_auth.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/api_views.cpython-314.pyc
+++ fossil/__pycache__/api_views.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/api_views.cpython-314.pyc
+++ fossil/__pycache__/api_views.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/api_views.cpython-314.pyc
+++ fossil/__pycache__/api_views.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/cli.cpython-314.pyc
+++ fossil/__pycache__/cli.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/cli.cpython-314.pyc
+++ fossil/__pycache__/cli.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/cli.cpython-314.pyc
+++ fossil/__pycache__/cli.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/tasks.cpython-314.pyc
+++ fossil/__pycache__/tasks.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/tasks.cpython-314.pyc
+++ fossil/__pycache__/tasks.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/tasks.cpython-314.pyc
+++ fossil/__pycache__/tasks.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/urls.cpython-314.pyc
+++ fossil/__pycache__/urls.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/urls.cpython-314.pyc
+++ fossil/__pycache__/urls.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/urls.cpython-314.pyc
+++ fossil/__pycache__/urls.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/views.cpython-314.pyc
+++ fossil/__pycache__/views.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/views.cpython-314.pyc
+++ fossil/__pycache__/views.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/views.cpython-314.pyc
+++ fossil/__pycache__/views.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/views.py
+++ fossil/views.py
@@ -1835,14 +1835,34 @@
18351835
18361836
Access control:
18371837
- Public repos: anonymous clone/pull allowed (no --localauth).
18381838
- Authenticated users with write access: full push/pull (--localauth).
18391839
- Private/internal repos: require at least read permission.
1840
+
1841
+ Supports HTTP Basic Auth for fossil CLI clients (push/pull/clone).
18401842
"""
1843
+ import base64
1844
+
1845
+ from django.contrib.auth import authenticate
1846
+
18411847
from projects.access import can_read_project, can_write_project
18421848
18431849
from .cli import FossilCLI
1850
+
1851
+ # Fossil CLI sends HTTP Basic Auth — Django's session middleware ignores it,
1852
+ # so we authenticate manually from the Authorization header.
1853
+ if not request.user.is_authenticated:
1854
+ auth_header = request.META.get("HTTP_AUTHORIZATION", "")
1855
+ if auth_header.startswith("Basic "):
1856
+ try:
1857
+ decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
1858
+ username, password = decoded.split(":", 1)
1859
+ user = authenticate(request, username=username, password=password)
1860
+ if user and user.is_active:
1861
+ request.user = user
1862
+ except (ValueError, UnicodeDecodeError):
1863
+ pass
18441864
18451865
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
18461866
fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
18471867
18481868
if request.method == "GET":
@@ -3226,10 +3246,16 @@
32263246
project, fossil_repo = _get_project_and_repo(slug, request, "read")
32273247
32283248
from fossil.releases import Release, ReleaseAsset
32293249
32303250
release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3251
+
3252
+ if release.is_draft:
3253
+ from projects.access import require_project_write
3254
+
3255
+ require_project_write(request, project)
3256
+
32313257
asset = get_object_or_404(ReleaseAsset, pk=asset_id, release=release, deleted_at__isnull=True)
32323258
32333259
# Increment download count atomically
32343260
ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
32353261
@@ -3244,10 +3270,15 @@
32443270
32453271
from fossil.releases import Release
32463272
32473273
release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
32483274
3275
+ if release.is_draft:
3276
+ from projects.access import require_project_write
3277
+
3278
+ require_project_write(request, project)
3279
+
32493280
if not release.checkin_uuid:
32503281
raise Http404("No checkin linked to this release.")
32513282
32523283
from .cli import FossilCLI
32533284
32543285
--- fossil/views.py
+++ fossil/views.py
@@ -1835,14 +1835,34 @@
1835
1836 Access control:
1837 - Public repos: anonymous clone/pull allowed (no --localauth).
1838 - Authenticated users with write access: full push/pull (--localauth).
1839 - Private/internal repos: require at least read permission.
 
 
1840 """
 
 
 
 
1841 from projects.access import can_read_project, can_write_project
1842
1843 from .cli import FossilCLI
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1844
1845 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1846 fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
1847
1848 if request.method == "GET":
@@ -3226,10 +3246,16 @@
3226 project, fossil_repo = _get_project_and_repo(slug, request, "read")
3227
3228 from fossil.releases import Release, ReleaseAsset
3229
3230 release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
 
 
 
 
 
 
3231 asset = get_object_or_404(ReleaseAsset, pk=asset_id, release=release, deleted_at__isnull=True)
3232
3233 # Increment download count atomically
3234 ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
3235
@@ -3244,10 +3270,15 @@
3244
3245 from fossil.releases import Release
3246
3247 release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3248
 
 
 
 
 
3249 if not release.checkin_uuid:
3250 raise Http404("No checkin linked to this release.")
3251
3252 from .cli import FossilCLI
3253
3254
--- fossil/views.py
+++ fossil/views.py
@@ -1835,14 +1835,34 @@
1835
1836 Access control:
1837 - Public repos: anonymous clone/pull allowed (no --localauth).
1838 - Authenticated users with write access: full push/pull (--localauth).
1839 - Private/internal repos: require at least read permission.
1840
1841 Supports HTTP Basic Auth for fossil CLI clients (push/pull/clone).
1842 """
1843 import base64
1844
1845 from django.contrib.auth import authenticate
1846
1847 from projects.access import can_read_project, can_write_project
1848
1849 from .cli import FossilCLI
1850
1851 # Fossil CLI sends HTTP Basic Auth — Django's session middleware ignores it,
1852 # so we authenticate manually from the Authorization header.
1853 if not request.user.is_authenticated:
1854 auth_header = request.META.get("HTTP_AUTHORIZATION", "")
1855 if auth_header.startswith("Basic "):
1856 try:
1857 decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
1858 username, password = decoded.split(":", 1)
1859 user = authenticate(request, username=username, password=password)
1860 if user and user.is_active:
1861 request.user = user
1862 except (ValueError, UnicodeDecodeError):
1863 pass
1864
1865 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1866 fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
1867
1868 if request.method == "GET":
@@ -3226,10 +3246,16 @@
3246 project, fossil_repo = _get_project_and_repo(slug, request, "read")
3247
3248 from fossil.releases import Release, ReleaseAsset
3249
3250 release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3251
3252 if release.is_draft:
3253 from projects.access import require_project_write
3254
3255 require_project_write(request, project)
3256
3257 asset = get_object_or_404(ReleaseAsset, pk=asset_id, release=release, deleted_at__isnull=True)
3258
3259 # Increment download count atomically
3260 ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
3261
@@ -3244,10 +3270,15 @@
3270
3271 from fossil.releases import Release
3272
3273 release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
3274
3275 if release.is_draft:
3276 from projects.access import require_project_write
3277
3278 require_project_write(request, project)
3279
3280 if not release.checkin_uuid:
3281 raise Http404("No checkin linked to this release.")
3282
3283 from .cli import FossilCLI
3284
3285
--- projects/__pycache__/access.cpython-314.pyc
+++ projects/__pycache__/access.cpython-314.pyc
cannot compute difference between binary files
11
--- projects/__pycache__/access.cpython-314.pyc
+++ projects/__pycache__/access.cpython-314.pyc
0 annot compute difference between binary files
1
--- projects/__pycache__/access.cpython-314.pyc
+++ projects/__pycache__/access.cpython-314.pyc
0 annot compute difference between binary files
1
--- tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc
+++ tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc
cannot compute difference between binary files
11
--- tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc
+++ tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc
0 annot compute difference between binary files
1
--- tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc
+++ tests/__pycache__/test_agent_coordination.cpython-314-pytest-9.0.2.pyc
0 annot compute difference between binary files
1
--- tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc
+++ tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc
cannot compute difference between binary files
11
--- tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc
+++ tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc
0 annot compute difference between binary files
1
--- tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc
+++ tests/__pycache__/test_releases.cpython-314-pytest-9.0.2.pyc
0 annot compute difference between binary files
1
--- tests/test_releases.py
+++ tests/test_releases.py
@@ -58,10 +58,25 @@
5858
file_size_bytes=len(b"fake-tarball-content"),
5959
content_type="application/gzip",
6060
created_by=admin_user,
6161
)
6262
63
+
64
+@pytest.fixture
65
+def draft_release_asset(draft_release, admin_user, tmp_path, settings):
66
+ settings.STORAGES = _TEST_STORAGES
67
+ settings.MEDIA_ROOT = str(tmp_path / "media")
68
+ uploaded = SimpleUploadedFile("beta-build.tar.gz", b"draft-content", content_type="application/gzip")
69
+ return ReleaseAsset.objects.create(
70
+ release=draft_release,
71
+ name="beta-build.tar.gz",
72
+ file=uploaded,
73
+ file_size_bytes=len(b"draft-content"),
74
+ content_type="application/gzip",
75
+ created_by=admin_user,
76
+ )
77
+
6378
6479
@pytest.mark.django_db
6580
class TestReleaseModel:
6681
def test_create_release(self, release):
6782
assert release.pk is not None
@@ -326,5 +341,31 @@
326341
assert release_asset.download_count == 2
327342
328343
def test_download_denied_on_private_for_no_perm(self, no_perm_client, sample_project, release, release_asset):
329344
response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/assets/{release_asset.pk}/")
330345
assert response.status_code == 403
346
+
347
+ def test_draft_asset_download_denied_for_non_writer(self, no_perm_client, sample_project, draft_release, draft_release_asset):
348
+ """Non-writers cannot download assets from draft releases even on public projects."""
349
+ sample_project.visibility = "public"
350
+ sample_project.save()
351
+ response = no_perm_client.get(
352
+ f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/assets/{draft_release_asset.pk}/"
353
+ )
354
+ assert response.status_code == 403
355
+
356
+ def test_draft_asset_download_allowed_for_writer(self, admin_client, sample_project, draft_release, draft_release_asset):
357
+ """Writers can download assets from draft releases."""
358
+ response = admin_client.get(
359
+ f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/assets/{draft_release_asset.pk}/"
360
+ )
361
+ assert response.status_code == 200
362
+
363
+
364
+@pytest.mark.django_db
365
+class TestDraftSourceArchiveAccess:
366
+ def test_draft_source_archive_denied_for_non_writer(self, no_perm_client, sample_project, draft_release):
367
+ """Non-writers cannot download source archives for draft releases."""
368
+ sample_project.visibility = "public"
369
+ sample_project.save()
370
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/source/tar.gz")
371
+ assert response.status_code == 403
331372
--- tests/test_releases.py
+++ tests/test_releases.py
@@ -58,10 +58,25 @@
58 file_size_bytes=len(b"fake-tarball-content"),
59 content_type="application/gzip",
60 created_by=admin_user,
61 )
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
64 @pytest.mark.django_db
65 class TestReleaseModel:
66 def test_create_release(self, release):
67 assert release.pk is not None
@@ -326,5 +341,31 @@
326 assert release_asset.download_count == 2
327
328 def test_download_denied_on_private_for_no_perm(self, no_perm_client, sample_project, release, release_asset):
329 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/assets/{release_asset.pk}/")
330 assert response.status_code == 403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
--- tests/test_releases.py
+++ tests/test_releases.py
@@ -58,10 +58,25 @@
58 file_size_bytes=len(b"fake-tarball-content"),
59 content_type="application/gzip",
60 created_by=admin_user,
61 )
62
63
64 @pytest.fixture
65 def draft_release_asset(draft_release, admin_user, tmp_path, settings):
66 settings.STORAGES = _TEST_STORAGES
67 settings.MEDIA_ROOT = str(tmp_path / "media")
68 uploaded = SimpleUploadedFile("beta-build.tar.gz", b"draft-content", content_type="application/gzip")
69 return ReleaseAsset.objects.create(
70 release=draft_release,
71 name="beta-build.tar.gz",
72 file=uploaded,
73 file_size_bytes=len(b"draft-content"),
74 content_type="application/gzip",
75 created_by=admin_user,
76 )
77
78
79 @pytest.mark.django_db
80 class TestReleaseModel:
81 def test_create_release(self, release):
82 assert release.pk is not None
@@ -326,5 +341,31 @@
341 assert release_asset.download_count == 2
342
343 def test_download_denied_on_private_for_no_perm(self, no_perm_client, sample_project, release, release_asset):
344 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/assets/{release_asset.pk}/")
345 assert response.status_code == 403
346
347 def test_draft_asset_download_denied_for_non_writer(self, no_perm_client, sample_project, draft_release, draft_release_asset):
348 """Non-writers cannot download assets from draft releases even on public projects."""
349 sample_project.visibility = "public"
350 sample_project.save()
351 response = no_perm_client.get(
352 f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/assets/{draft_release_asset.pk}/"
353 )
354 assert response.status_code == 403
355
356 def test_draft_asset_download_allowed_for_writer(self, admin_client, sample_project, draft_release, draft_release_asset):
357 """Writers can download assets from draft releases."""
358 response = admin_client.get(
359 f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/assets/{draft_release_asset.pk}/"
360 )
361 assert response.status_code == 200
362
363
364 @pytest.mark.django_db
365 class TestDraftSourceArchiveAccess:
366 def test_draft_source_archive_denied_for_non_writer(self, no_perm_client, sample_project, draft_release):
367 """Non-writers cannot download source archives for draft releases."""
368 sample_project.visibility = "public"
369 sample_project.save()
370 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/source/tar.gz")
371 assert response.status_code == 403
372

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button