diff --git a/cloudfile_ext/settings_defaults.py b/cloudfile_ext/settings_defaults.py index ab5476b408..3c27ada3b3 100644 --- a/cloudfile_ext/settings_defaults.py +++ b/cloudfile_ext/settings_defaults.py @@ -50,6 +50,9 @@ CF_ENABLE_FILE_PREVIEW = True CF_ENABLE_FILE_LOCK = False CF_ENABLE_CHECKOUT = False +# Only the selected Repo UUIDs support OPTIONAL Checkout. Empty = all OFF. +# Generated together with native seafile.conf; no per-request database lookup. +CF_CHECKOUT_REPOS = () CF_ENABLE_FAVORITES_ID = True CF_ENABLE_WATCH = True CF_ENABLE_FILEOPS = True diff --git a/cloudfile_ext/v2/rust_edit.py b/cloudfile_ext/v2/rust_edit.py index 1e23b7d62e..90427a23f7 100644 --- a/cloudfile_ext/v2/rust_edit.py +++ b/cloudfile_ext/v2/rust_edit.py @@ -54,6 +54,13 @@ def checkout_available(): return path_checkout_available(connection, schema) +def checkout_for_repo(repo_id): + """In-memory repository opt-in. OFF libraries never inspect Guard schema.""" + selected = getattr(settings, 'CF_CHECKOUT_REPOS', ()) + return (isinstance(selected, (tuple, list, set, frozenset)) and + repo_id in selected and checkout_available()) + + def issue_ticket(*, username, uid, repo_id, path, mode, file_id, now, secret): """Stateless, path-scoped Agent permit. Never accept a caller-supplied actor.""" if not isinstance(username, str) or not username or len(username) > 255: @@ -97,7 +104,7 @@ def post(self, request): mode = request.data["mode"] if mode not in ("local-view", "optimistic-edit", "exclusive-edit"): return Response({"code": "BAD_EDIT_REQUEST"}, status=400) - if mode == "exclusive-edit" and not checkout_available(): + if mode == "exclusive-edit" and not checkout_for_repo(repo_id): return Response({"code": "CHECKOUT_UNSUPPORTED"}, status=409) user = request.user.username uid = Profile.objects.get(user=user).login_id @@ -155,15 +162,15 @@ class RustCheckoutInfo(APIView): throttle_classes = (UserRateThrottle,) def get(self, request): - if not checkout_available(): - # Read-only capability information must not disable ordinary edits. - return Response({"checkout_supported": False, "checked_out": False}) try: args = request.query_params if set(args) != {"repo_id", "path"} or any(len(args.getlist(k)) != 1 for k in args): raise ValueError() repo_id, path = args["repo_id"], args["path"] UUID(repo_id) + if not checkout_for_repo(repo_id): + # OFF: no Guard SQL or schema discovery; ordinary edits remain available. + return Response({"checkout_supported": False, "checked_out": False}) if (not path.startswith("/") or path == "/" or "\\" in path or "//" in path or len(path.encode("utf-8")) > 4096 or any(x in (".", "..") for x in path.split("/"))): @@ -193,7 +200,8 @@ def get(self, request): if expires is None or expires <= timezone.now().replace(tzinfo=None): return Response({"checkout_supported": True, "checked_out": False}) current_uid = Profile.objects.get(user=viewer).login_id - privileged = owner_uid == current_uid or bool(request.user.is_staff) + owned_by_me = owner_uid == current_uid + privileged = owned_by_me or bool(request.user.is_staff) details = { "checkout_supported": True, "checked_out": True, @@ -202,6 +210,9 @@ def get(self, request): "checked_out_at": started.isoformat() + "Z", "last_activity_at": active.isoformat() + "Z", "expires_at": expires.isoformat() + "Z", + # The dialog may offer resume only to the Guard owner; the Rust + # Agent still verifies the original Guard proof before resuming. + "checked_out_by_me": owned_by_me, } if privileged: details.update(terminal_type=terminal, terminal_name=name, diff --git a/cloudfile_ext/v2/tests/test_rust_edit_view.py b/cloudfile_ext/v2/tests/test_rust_edit_view.py index 2e71d2b59f..7a4a708fcf 100644 --- a/cloudfile_ext/v2/tests/test_rust_edit_view.py +++ b/cloudfile_ext/v2/tests/test_rust_edit_view.py @@ -8,7 +8,8 @@ import jwt from rest_framework.test import APIRequestFactory, force_authenticate -from cloudfile_ext.v2.rust_edit import RustLocalEditOpen, issue_ticket, checkout_available +from cloudfile_ext.v2.rust_edit import (RustLocalEditOpen, RustCheckoutInfo, + issue_ticket, checkout_available, checkout_for_repo) RID = "11111111-1111-4111-8111-111111111111" @@ -21,7 +22,7 @@ def request(self, mode, *, read, write): req = APIRequestFactory().post( "/api/v2.1/cloudfile/edit/v1/open/", {"repo_id": RID, "path": FILE, "mode": mode}, format="json") - force_authenticate(req, SimpleNamespace(username="reader@auth.local", + force_authenticate(req, SimpleNamespace(id=1, username="reader@auth.local", is_authenticated=True, is_active=True)) policy = SimpleNamespace(can_read=read, can_write=write) repo = SimpleNamespace(is_virtual=False, encrypted=False, version=1) @@ -30,7 +31,7 @@ def request(self, mode, *, read, write): file_entry = SimpleNamespace(mode=stat.S_IFREG | 0o644, obj_id=FILE_ID) # Scope denial is tested with Checkout installed; schema discovery has # its own tests and must not turn this authorization case into HTTP 503. - with patch("cloudfile_ext.v2.rust_edit.checkout_available", return_value=True), \ + with patch("cloudfile_ext.v2.rust_edit.checkout_for_repo", return_value=True), \ patch("cloudfile_ext.v2.rust_edit.Profile.objects.get", return_value=SimpleNamespace(login_id="stable-uid")), \ patch("cloudfile_ext.v2.rust_edit.Authority") as auth, \ @@ -76,6 +77,49 @@ def test_view_claim_is_issued_only_for_explicit_local_view(self): class CheckoutCapabilityTests(unittest.TestCase): + def test_checkout_is_off_without_explicit_repo_selection(self): + with patch('cloudfile_ext.v2.rust_edit.settings', + SimpleNamespace(CF_CHECKOUT_REPOS=())), \ + patch('cloudfile_ext.v2.rust_edit.checkout_available') as available: + self.assertFalse(checkout_for_repo(RID)) + available.assert_not_called() + + def test_checkout_only_for_selected_repo(self): + with patch('cloudfile_ext.v2.rust_edit.settings', + SimpleNamespace(CF_CHECKOUT_REPOS=(RID,))), \ + patch('cloudfile_ext.v2.rust_edit.checkout_available', return_value=True): + self.assertTrue(checkout_for_repo(RID)) + self.assertFalse(checkout_for_repo('22222222-2222-4222-8222-222222222222')) + + def test_selected_repo_is_still_off_when_global_capability_is_disabled(self): + with patch('cloudfile_ext.v2.rust_edit.settings', + SimpleNamespace(CF_CHECKOUT_REPOS=(RID,))), \ + patch('cloudfile_ext.v2.rust_edit.checkout_available', return_value=False): + self.assertFalse(checkout_for_repo(RID)) + + def test_malformed_selection_type_fails_safe_to_off(self): + with patch('cloudfile_ext.v2.rust_edit.settings', + SimpleNamespace(CF_CHECKOUT_REPOS=RID)), \ + patch('cloudfile_ext.v2.rust_edit.checkout_available') as available: + self.assertFalse(checkout_for_repo(RID)) + available.assert_not_called() + + def test_off_repo_lock_info_never_queries_guard(self): + req = APIRequestFactory().get('/api/v2.1/cloudfile/edit/v1/lock-info/', + {'repo_id': RID, 'path': FILE}) + force_authenticate(req, SimpleNamespace(id=1, username='reader@auth.local', + is_authenticated=True, is_active=True)) + # Exercise the real helper: an empty allowlist must short-circuit before + # checkout_available()/schema discovery, without any Guard SQL. + with patch('cloudfile_ext.v2.rust_edit.settings', + SimpleNamespace(CF_CHECKOUT_REPOS=())), \ + patch('cloudfile_ext.v2.rust_edit.connection') as database: + response = RustCheckoutInfo.as_view()(req) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['checkout_supported'], False) + database.cursor.assert_not_called() + + def test_disabled_checkout_never_discovers_schema(self): checkout_available.cache_clear() try: diff --git a/docs/checkout-per-repo-policy.md b/docs/checkout-per-repo-policy.md new file mode 100644 index 0000000000..00743da7e2 --- /dev/null +++ b/docs/checkout-per-repo-policy.md @@ -0,0 +1,61 @@ +# CloudFile repository-scoped Checkout (OFF / OPTIONAL) + +Decision: 2026-10-25. CloudFile supports exactly two library policies. No REQUIRED mode. + +| Library policy | Default | Behavior | +| --- | --- | --- | +| OFF | Yes | Seafile read/write/versioning. No Checkout Guard SQL or native Guard write fence for that repository. | +| OPTIONAL | Explicit Repo UUID allowlist | An authorized user can Checkout a file, Commit repeatedly while retaining Guard, and Checkin to publish and release it. Unchecked-out files remain normally editable. | + +**No file-extension-based enablement, mandatory sign-out, extra lock/intent/session +tables, or database-backed per-request library policy checks.** + +## Configuration + +`CF_ENABLE_CHECKOUT` remains the global capability switch, default `false`. +`CF_CHECKOUT_REPOS` is a comma-separated list of canonical Seafile Repo UUIDs, +default empty. Both must be set to enable a repository. Docker bootstrap writes +the same normalized selection into Hub `CF_CHECKOUT_REPOS` (tuple) and native +`seafile.conf [cloudfile] checkout_repo_ids`, read by both Go and C. +The policy is loaded at startup; change it in a coordinated restart. + +No policy fields are added to `cf_library_storage`, which only tracks storage bindings. +No special Etech provider behavior is needed. Etech or other PDM integrations may +opt their chosen libraries into OPTIONAL without modifying normal repositories. + +## Runtime boundary + +1. On an OFF library, Hub denies exclusive-edit tickets, returns + `checkout_supported=false` without Guard access, and continues issuing + authorized `local-view` and `optimistic-edit` tickets. +2. On OFF libraries, C/Go native operations skip Guard SQL and related + Branch-diff/locking logic entirely. Existing ACL/CAS remain authoritative. +3. On OPTIONAL libraries, the native Guard remains authoritative, including + ordinary upload and other unscoped write paths. A missing or invalid Guard + schema fails closed **only for affected libraries**. +4. Agent Rust uses its existing work copy, durable Journal, proof/generation, + status and explicit Commit/Checkin; it does not implement a parallel policy. +5. Hub PR #50 must query capability only for relevant local actions and allow + the confirmed owner to resume an existing task. Agent2 #10/#1 require + real Windows/macOS three-mode end-to-end evidence before release. + +## Operations and migration + +Never remove a repository from the allowlist, or disable the global capability, +while it holds an active Guard or has unresolved Agent changes. First stop +new Checkouts, resolve Commit/Checkin or Cancel, inspect active lease rows, then +update the deployment config and restart Hub, Go, and C together. + +Deployments previously using global Checkout must enumerate still-locked Repo +UUIDs and include them in the new allowlist before rollout. An empty new list +means **all OFF**, not the old global behavior. An invalid UUID list is rejected +at configuration loading. OFF does not implicitly release an old Guard. + +## Minimal acceptance + +- OFF: view/optimistic edit/normal upload; zero Guard SQL in hot write path. +- OPTIONAL with a healthy Guard and no active lease: ordinary edits and explicit Checkout both available. +- OPTIONAL with active Guard: only owner with proof can Commit/Checkin; + other writes fail and reads succeed. +- Guard unavailable: OPTIONAL writes (including ordinary/optimistic writes) fail closed while OFF stays operational. +- Switching OPTIONAL -> OFF requires confirmed lease drain and service restart.