This commit is contained in:
Louis Mazin 2026-10-14 02:29:33 +02:00
parent 9c0c7b65dc
commit ac4c0f4d6a
5 changed files with 155 additions and 34 deletions

View File

@ -0,0 +1,21 @@
"""Count private files in their owner's assigned quota, without reservation.
Only the SQL parent changes. Files remain under storage/privates/ (or at their
legacy path until move_private_storage is run). 50 Go is a cap, not a reserve.
"""
from django.db import migrations
def attach_privates(apps, schema_editor):
Space = apps.get_model('drive', 'DriveSpace')
quota = 50 * 1024 ** 3
for private in Space.objects.filter(private_owner__isnull=False).iterator():
assigned = Space.objects.filter(user_id=private.private_owner_id, private_owner__isnull=True).first()
if not assigned:
raise RuntimeError(f'Le compte {private.private_owner_id} ne possède pas d’espace attribué.')
Space.objects.filter(pk=private.pk).update(parent_id=assigned.pk, quota_bytes=quota)
class Migration(migrations.Migration):
dependencies = [('drive', '0013_private_space_quota')]
operations = [migrations.RunPython(attach_privates, migrations.RunPython.noop)]

View File

@ -1,15 +1,15 @@
"""Space quotas, including descendants, trash, and reserved resumable uploads. """Space quotas, including logical descendants, trash, and pending uploads.
A child can be constrained by both its own allocation and its ancestors'. A child can be constrained by both its own allocation and its ancestors'.
Call assert_space_capacity() inside transaction.atomic() before reserving/writing. Call assert_space_capacity() inside transaction.atomic() before reserving/writing.
""" """
from contextlib import contextmanager from contextlib import contextmanager
from django.db import transaction from django.db import transaction
from django.db.models import Q, Sum from django.db.models import Sum
from .models import DriveItem, DriveSpace, UploadSession from .models import DriveItem, DriveSpace, UploadSession
MAX_QUOTA_BYTES = 9_007_199_254_740_991 # exact integer in JS too MAX_QUOTA_BYTES = 9_007_199_254_740_991 # exact integer in JS too
PRIVATE_SPACE_QUOTA_BYTES = 50 * 1024 ** 3 # 50 Gio per private account (shown as 50 Go) PRIVATE_SPACE_QUOTA_BYTES = 50 * 1024 ** 3 # Maximum privé, non réservé dans le quota attribué
class QuotaExceeded(Exception): class QuotaExceeded(Exception):
@ -47,14 +47,24 @@ def ancestry(space):
def subtree_space_ids(space): def subtree_space_ids(space):
"""Use the stable DB path rather than a filesystem traversal.""" """Resolve the logical quota subtree, not the filesystem path.
# A shared root contains only shared spaces: private spaces have their
# own quota and must never consume/reserve shared root capacity. Private accounts store files below storage/privates/ but are children of
if space.parent_id is None and space.relative_path == '': their assigned spaces *for quota accounting*. Physical path-prefix
return DriveSpace.objects.filter(private_owner__isnull=True).values_list('pk', flat=True) queries would silently omit their real disk usage.
return DriveSpace.objects.filter( """
Q(relative_path=space.relative_path) | Q(relative_path__startswith=space.relative_path + '/') by_parent = {}
).values_list('pk', flat=True) for pk, parent_id in DriveSpace.objects.values_list('pk', 'parent_id'):
by_parent.setdefault(parent_id, []).append(pk)
result = set()
to_visit = [space.pk]
while to_visit:
pk = to_visit.pop()
if pk in result:
raise ValueError('Hiérarchie des espaces invalide.')
result.add(pk)
to_visit.extend(by_parent.get(pk, []))
return result
def occupancy(space): def occupancy(space):
@ -98,9 +108,9 @@ def trash_occupancy(space):
def _budget_tree(space): def _budget_tree(space):
"""One DB snapshot of all descendant spaces and their direct physical usage. """One DB snapshot of all descendant spaces and their direct physical usage.
A child's fixed quota reserves that much capacity for it; a child without Shared descendants retain the existing reservation rules for assigned
a quota reserves only its actual consumption (including its descendants). quotas. A private space's 50-Go cap is NEVER preallocated: its parent counts
Thus quotas of siblings cannot silently overcommit their common parent. only its real files and pending uploads (including its own descendants).
""" """
ids=list(subtree_space_ids(space)) ids=list(subtree_space_ids(space))
nodes={node.pk:node for node in DriveSpace.objects.filter(pk__in=ids)} nodes={node.pk:node for node in DriveSpace.objects.filter(pk__in=ids)}
@ -115,7 +125,11 @@ def _budget_tree(space):
memo={} memo={}
def committed(pk): def committed(pk):
if pk not in memo: if pk not in memo:
memo[pk]=direct[pk]+sum(max(nodes[c].quota_bytes or 0,committed(c)) for c in children[pk]) memo[pk] = direct[pk] + sum(
committed(child) if nodes[child].private_owner_id
else max(nodes[child].quota_bytes or 0, committed(child))
for child in children[pk]
)
return memo[pk] return memo[pk]
committed(space.pk) committed(space.pk)
return memo return memo
@ -129,8 +143,9 @@ def _committed(space):
def available_for_growth(space): def available_for_growth(space):
"""Bytes that can be added directly to space without breaking any ancestor allocation. """Bytes that can be added directly to space without breaking any ancestor allocation.
Capacity already reserved for this child does not count against its parent's Quotas of normal assigned children can absorb growth already reserved in
available budget a second time. A zero-byte quota correctly permits no growth. their ancestors. For private children, the 50-Go cap is NOT reserved: every
newly uploaded byte immediately reduces the parent's available budget.
""" """
maximum=MAX_QUOTA_BYTES maximum=MAX_QUOTA_BYTES
absorbed=0 absorbed=0
@ -140,7 +155,9 @@ def available_for_growth(space):
committed=committed_tree[ancestor.pk] committed=committed_tree[ancestor.pk]
if ancestor.quota_bytes is not None: if ancestor.quota_bytes is not None:
maximum=min(maximum,ancestor.quota_bytes-committed+absorbed) maximum=min(maximum,ancestor.quota_bytes-committed+absorbed)
absorbed+=max(0,ancestor.quota_bytes-committed) # Private caps constrain uploads, but never absorb parent capacity.
if not ancestor.private_owner_id:
absorbed+=max(0,ancestor.quota_bytes-committed)
return None if maximum==MAX_QUOTA_BYTES else max(0,int(maximum)) return None if maximum==MAX_QUOTA_BYTES else max(0,int(maximum))
@ -203,7 +220,7 @@ def locked_quota_tree(space):
def assert_space_capacity(space, additional_bytes): def assert_space_capacity(space, additional_bytes):
"""Called with ancestor locks; include sibling allocations when checking uploads.""" """Called with ancestor locks; private uploads count toward both caps."""
available=available_for_growth(space) available=available_for_growth(space)
if available is not None and additional_bytes>available: if available is not None and additional_bytes>available:
raise QuotaExceeded( raise QuotaExceeded(

View File

@ -1,4 +1,4 @@
"""Physical numbered directories and strict permissions between personal/shared spaces.""" """Private files stored separately but counted against the assigned-space quota."""
import hashlib import hashlib
import secrets import secrets
from pathlib import Path from pathlib import Path
@ -34,10 +34,13 @@ def ensure_space(user):
def _make_numbered_space(parent, **fields): def _make_numbered_space(parent, **fields):
# Private directories live in a separate physical and quota hierarchy. # Private directories are physically separate, but logically child spaces
# of the account's assigned space so their *actual bytes* are included.
private = fields.get('private_owner') is not None private = fields.get('private_owner') is not None
if private != (parent is None): if parent is None or parent.private_owner_id:
raise ValueError('Hiérarchie de stockage privée invalide.') raise ValueError('Un espace doit avoir un parent partagé.')
if private and parent.user_id != fields['private_owner'].pk:
raise ValueError('L’espace privé doit être rattaché au quota attribué à son propriétaire.')
base = 'privates' if private else parent.relative_path base = 'privates' if private else parent.relative_path
if len(base) + 1 + 24 > 700: if len(base) + 1 + 24 > 700:
raise ValueError('Hiérarchie trop profonde.') raise ValueError('Hiérarchie trop profonde.')
@ -95,8 +98,9 @@ def ensure_private_space(user):
obj = DriveSpace.objects.select_for_update().filter(private_owner=user).first() obj = DriveSpace.objects.select_for_update().filter(private_owner=user).first()
if obj: if obj:
return obj return obj
ensure_space(user) # Account must still have its assigned/shared space. assigned = ensure_space(user)
return _make_numbered_space(None, private_owner=user, display_name='Mes fichiers') # The 50-Go cap is only a limit on private uploads, not a reservation.
return _make_numbered_space(assigned, private_owner=user, display_name='Mes fichiers')
def my_space(user): def my_space(user):

View File

@ -1,4 +1,4 @@
"""Focused regressions for separate private storage and its 50 Go quota. """Private disk layout, 50-Go private cap, and real-byte pooled accounting.
Run with: USE_SQLITE=true python manage.py test drive.test_private_layout Run with: USE_SQLITE=true python manage.py test drive.test_private_layout
""" """
@ -15,7 +15,8 @@ from django.test import TestCase, override_settings
from rest_framework.test import APIClient from rest_framework.test import APIClient
from .models import DriveItem, DriveSpace from .models import DriveItem, DriveSpace
from .quotas import PRIVATE_SPACE_QUOTA_BYTES, QuotaExceeded, assert_space_capacity, locked_quota_tree, occupancy, quota_state from .quotas import (PRIVATE_SPACE_QUOTA_BYTES, QuotaExceeded, assert_space_capacity,
assert_updated_quota, locked_quota_tree, occupancy, quota_state)
from .spaces import ensure_private_space, ensure_space, create_space, storage_path from .spaces import ensure_private_space, ensure_space, create_space, storage_path
@ -38,7 +39,7 @@ class PrivateStorageTests(TestCase):
self.client.force_authenticate(user=self.admin) self.client.force_authenticate(user=self.admin)
def test_private_directories_are_separate_from_shared_spaces(self): def test_private_directories_are_separate_from_shared_spaces(self):
self.assertIsNone(self.private.parent_id) self.assertEqual(self.private.parent_id, self.shared.pk)
self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}') self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}')
self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES) self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES)
self.assertTrue((self.rootdir / self.private.relative_path).is_dir()) self.assertTrue((self.rootdir / self.private.relative_path).is_dir())
@ -47,12 +48,15 @@ class PrivateStorageTests(TestCase):
private = ensure_private_space(person) private = ensure_private_space(person)
self.assertTrue(shared.relative_path.startswith(f'{shared.directory_name}')) self.assertTrue(shared.relative_path.startswith(f'{shared.directory_name}'))
self.assertEqual(private.relative_path, f'privates/{private.directory_name}') self.assertEqual(private.relative_path, f'privates/{private.directory_name}')
self.assertIsNone(private.parent_id) self.assertEqual(private.parent_id, shared.pk)
self.assertNotEqual(shared.directory_name, private.directory_name) self.assertNotEqual(shared.directory_name, private.directory_name)
def test_private_quota_does_not_consume_shared_parent(self): def test_private_usage_counts_toward_assigned_space_without_reserving(self):
self.shared.quota_bytes = 1 self.shared.quota_bytes = 100
self.shared.save(update_fields=['quota_bytes']) self.shared.save(update_fields=['quota_bytes'])
# Private can be 50 Go even when the assigned budget is just 100 bytes.
self.assertEqual(quota_state(self.shared)['available_bytes'], 100)
self.assertEqual(quota_state(self.private)['available_bytes'], 100)
created = self.client.post('/api/drive/uploads/', { created = self.client.post('/api/drive/uploads/', {
'files': [SimpleUploadedFile('photo.jpg', b'abcdef')] 'files': [SimpleUploadedFile('photo.jpg', b'abcdef')]
}, format='multipart') }, format='multipart')
@ -60,7 +64,9 @@ class PrivateStorageTests(TestCase):
node = DriveItem.objects.get(pk=created.data['items'][0]['id']) node = DriveItem.objects.get(pk=created.data['items'][0]['id'])
self.assertEqual(node.storage_location, f'{self.private.relative_path}/photo.jpg') self.assertEqual(node.storage_location, f'{self.private.relative_path}/photo.jpg')
self.assertEqual(storage_path(node).read_bytes(), b'abcdef') self.assertEqual(storage_path(node).read_bytes(), b'abcdef')
self.assertEqual(occupancy(self.shared)[0], 0) self.assertEqual(occupancy(self.shared)[0], 6)
self.assertEqual(quota_state(self.shared)['available_bytes'], 94)
self.assertEqual(quota_state(self.private)['available_bytes'], 94)
self.assertEqual(quota_state(self.private)['quota_bytes'], PRIVATE_SPACE_QUOTA_BYTES) self.assertEqual(quota_state(self.private)['quota_bytes'], PRIVATE_SPACE_QUOTA_BYTES)
self.assertEqual(quota_state(self.private)['used_bytes'], 6) self.assertEqual(quota_state(self.private)['used_bytes'], 6)
rejected = self.client.patch(f'/api/drive/spaces/{self.private.pk}/quota/', { rejected = self.client.patch(f'/api/drive/spaces/{self.private.pk}/quota/', {
@ -68,6 +74,78 @@ class PrivateStorageTests(TestCase):
}, format='json') }, format='json')
self.assertEqual(rejected.status_code, 403) self.assertEqual(rejected.status_code, 403)
def test_actual_private_use_is_pooled_in_100_go_assigned_space(self):
person = get_user_model().objects.create_user('quota-user', password='TestPassword123!')
assigned = create_space(person, self.shared, '100 Go', quota_bytes=100 * 1024 ** 3)
private = ensure_private_space(person)
gib = 1024 ** 3
self.assertEqual(private.parent_id, assigned.pk)
self.assertEqual(private.relative_path, f'privates/{private.directory_name}')
self.assertEqual(quota_state(assigned)['available_bytes'], 100 * gib)
self.assertEqual(quota_state(private)['available_bytes'], 50 * gib)
# Existing 20 Go in shared space. This leaves both limits at their max.
DriveItem.objects.create(
owner=person, space=assigned, name='shared.bin',
storage_key=str(uuid.uuid4()), storage_location=f'{assigned.relative_path}/shared.bin',
size=20 * gib,
)
self.assertEqual(quota_state(assigned)['used_bytes'], 20 * gib)
self.assertEqual(quota_state(private)['used_bytes'], 0)
DriveItem.objects.create(
owner=person, space=private, name='private.bin',
storage_key=str(uuid.uuid4()), storage_location=f'{private.relative_path}/private.bin',
size=20 * gib,
)
self.assertEqual(occupancy(assigned)[0], 40 * gib)
self.assertEqual(quota_state(assigned)['used_bytes'], 40 * gib)
self.assertEqual(quota_state(assigned)['available_bytes'], 60 * gib)
self.assertEqual(quota_state(private)['used_bytes'], 20 * gib)
self.assertEqual(quota_state(private)['available_bytes'], 30 * gib)
with transaction.atomic():
with locked_quota_tree(assigned):
assert_space_capacity(assigned, 60 * gib)
with self.assertRaises(QuotaExceeded):
assert_space_capacity(assigned, 60 * gib + 1)
assert_updated_quota(assigned, 40 * gib)
with self.assertRaisesRegex(ValueError, 'fichiers'):
assert_updated_quota(assigned, 39 * gib)
def test_parent_limit_blocks_private_even_before_50_go(self):
self.shared.quota_bytes = 20
self.shared.save(update_fields=['quota_bytes'])
DriveItem.objects.create(
owner=self.admin, space=self.private, name='existing.bin',
storage_key=str(uuid.uuid4()), storage_location=f'{self.private.relative_path}/existing.bin', size=19,
)
self.assertEqual(quota_state(self.private)['available_bytes'], 1)
with transaction.atomic():
with locked_quota_tree(self.private):
assert_space_capacity(self.private, 1)
with self.assertRaises(QuotaExceeded):
assert_space_capacity(self.private, 2)
def test_invitations_allow_less_than_50_go_no_reservation(self):
self.shared.quota_bytes = 15
self.shared.save(update_fields=['quota_bytes'])
res = self.client.post('/api/drive/invitations/', {'quota_bytes': 10}, format='json')
self.assertEqual(res.status_code, 201, res.data)
self.assertEqual(quota_state(self.shared)['available_bytes'], 15)
member = get_user_model().objects.create_user('tiny-private', password='TestPassword123!')
assigned = create_space(member, self.shared, '10 octets', quota_bytes=10)
private = ensure_private_space(member)
self.assertEqual(private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES)
self.assertEqual(quota_state(assigned)['available_bytes'], 10)
self.assertEqual(quota_state(private)['available_bytes'], 10)
def test_private_pending_uploads_count_in_parent(self):
from .models import UploadSession
UploadSession.objects.create(
owner=self.admin, space=self.private, name='movie.bin', total_size=1234
)
self.assertEqual(quota_state(self.shared)['reserved_bytes'], 1234)
self.assertEqual(quota_state(self.shared)['used_bytes'], 0)
self.assertEqual(quota_state(self.private)['available_bytes'], PRIVATE_SPACE_QUOTA_BYTES - 1234)
def test_private_50_go_upload_limit_is_enforced(self): def test_private_50_go_upload_limit_is_enforced(self):
DriveItem.objects.create( DriveItem.objects.create(
owner=self.admin, space=self.private, name='large.bin', owner=self.admin, space=self.private, name='large.bin',
@ -114,7 +192,7 @@ class PrivateStorageTests(TestCase):
self.private.refresh_from_db() self.private.refresh_from_db()
item.refresh_from_db() item.refresh_from_db()
self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}') self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}')
self.assertIsNone(self.private.parent_id) self.assertEqual(self.private.parent_id, self.shared.pk)
self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES) self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES)
self.assertEqual(item.storage_location, f'{self.private.relative_path}/Documents/facture.pdf') self.assertEqual(item.storage_location, f'{self.private.relative_path}/Documents/facture.pdf')
self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT') self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT')

View File

@ -18,7 +18,8 @@ from rest_framework.response import Response
from .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession from .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession
from .services import error, valid_name from .services import error, valid_name
from .spaces import ensure_space, ensure_private_space, my_space, create_space, _make_numbered_space, is_admin, hash_token, space_info, can_read_space, can_manage_space from .spaces import ensure_space, ensure_private_space, my_space, create_space, _make_numbered_space, is_admin, hash_token, space_info, can_read_space, can_manage_space
from .quotas import parse_quota, locked_quota_tree, assert_new_child_quota, assert_updated_quota from .quotas import (parse_quota, locked_quota_tree,
assert_new_child_quota, assert_updated_quota)
def permission_flags(data): def permission_flags(data):