From 0d586a5c370fdc14b291210a4c55a6e9adae5436 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 12:32:12 +0900 Subject: [PATCH 1/8] feat(checkout): default every library to OFF in Hub --- cloudfile_ext/settings_defaults.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cloudfile_ext/settings_defaults.py b/cloudfile_ext/settings_defaults.py index ab5476b4081..3c27ada3b36 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 From bcd4cd02151517fe19a55401f87ce9886577bebb Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 12:32:15 +0900 Subject: [PATCH 2/8] feat(checkout): scope Hub tickets and lock-info by optional repositories --- cloudfile_ext/v2/rust_edit.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/cloudfile_ext/v2/rust_edit.py b/cloudfile_ext/v2/rust_edit.py index 1e23b7d62ee..3f107ef11e7 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("/"))): From 509198326b08eee0cf0473373eea08027b87bfa5 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 12:32:19 +0900 Subject: [PATCH 3/8] test(checkout): OFF lock-info avoids Guard; selected only --- cloudfile_ext/v2/tests/test_rust_edit_view.py | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/cloudfile_ext/v2/tests/test_rust_edit_view.py b/cloudfile_ext/v2/tests/test_rust_edit_view.py index 2e71d2b59f4..7f6898754eb 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" @@ -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,33 @@ 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_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(username='reader@auth.local', + is_authenticated=True, is_active=True)) + with patch('cloudfile_ext.v2.rust_edit.checkout_for_repo', return_value=False), \ + 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: From c9d697d61c85ba0756f5cb33921431d108691db4 Mon Sep 17 00:00:00 2001 From: servicememeai Date: Sun, 25 Oct 2026 12:34:45 +0900 Subject: [PATCH 4/8] docs(checkout): accept OFF default and OPTIONAL per repository; no REQUIRED --- docs/checkout-per-repo-policy.md | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/checkout-per-repo-policy.md diff --git a/docs/checkout-per-repo-policy.md b/docs/checkout-per-repo-policy.md new file mode 100644 index 00000000000..fadd34ba08a --- /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 without Guard: 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 fail closed while OFF stays operational. +- Switching OPTIONAL -> OFF requires confirmed lease drain and service restart. From 79502c21f68d6513dac103205a58e7abca6e617b Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Sun, 25 Oct 2026 11:47:24 +0800 Subject: [PATCH 5/8] fix(checkout): align OPTIONAL/no-Guard acceptance and cover OFF fail-safes - design doc no longer claims ordinary writes work when Guard is unavailable; OPTIONAL writes (including optimistic) fail closed, OFF stays operational - tests: selected repo is still OFF when the global capability is disabled; a malformed CF_CHECKOUT_REPOS type fails safe to OFF without Guard access --- cloudfile_ext/v2/tests/test_rust_edit_view.py | 13 +++++++++++++ docs/checkout-per-repo-policy.md | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/cloudfile_ext/v2/tests/test_rust_edit_view.py b/cloudfile_ext/v2/tests/test_rust_edit_view.py index 7f6898754eb..7000c1bb8af 100644 --- a/cloudfile_ext/v2/tests/test_rust_edit_view.py +++ b/cloudfile_ext/v2/tests/test_rust_edit_view.py @@ -91,6 +91,19 @@ def test_checkout_only_for_selected_repo(self): 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}) diff --git a/docs/checkout-per-repo-policy.md b/docs/checkout-per-repo-policy.md index fadd34ba08a..00743da7e27 100644 --- a/docs/checkout-per-repo-policy.md +++ b/docs/checkout-per-repo-policy.md @@ -54,8 +54,8 @@ 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 without Guard: ordinary edits and explicit Checkout both available. +- 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 fail closed while OFF stays operational. +- Guard unavailable: OPTIONAL writes (including ordinary/optimistic writes) fail closed while OFF stays operational. - Switching OPTIONAL -> OFF requires confirmed lease drain and service restart. From 454d26f057ebb56095f63544181b68935ce84735 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Sun, 25 Oct 2026 11:52:52 +0800 Subject: [PATCH 6/8] test(checkout): exercise the real OFF short-circuit in lock-info Patch the empty allowlist (not the helper) so the test proves checkout_available and therefore Guard schema/SQL are never reached for OFF repositories. --- cloudfile_ext/v2/tests/test_rust_edit_view.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cloudfile_ext/v2/tests/test_rust_edit_view.py b/cloudfile_ext/v2/tests/test_rust_edit_view.py index 7000c1bb8af..bb01f415fb3 100644 --- a/cloudfile_ext/v2/tests/test_rust_edit_view.py +++ b/cloudfile_ext/v2/tests/test_rust_edit_view.py @@ -109,7 +109,10 @@ def test_off_repo_lock_info_never_queries_guard(self): {'repo_id': RID, 'path': FILE}) force_authenticate(req, SimpleNamespace(username='reader@auth.local', is_authenticated=True, is_active=True)) - with patch('cloudfile_ext.v2.rust_edit.checkout_for_repo', return_value=False), \ + # 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) From b1b477e959b52566e728cf01ab9edf338bbd36bf Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Sun, 25 Oct 2026 12:15:44 +0800 Subject: [PATCH 7/8] feat(checkout): expose checked_out_by_me for owner task resume lock-info now tells the dialog whether the current viewer owns the Guard. The Rust Agent still verifies the original Guard proof; this flag only distinguishes the owner's resume entry from another user's occupancy. No new table or ticket type. --- cloudfile_ext/v2/rust_edit.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cloudfile_ext/v2/rust_edit.py b/cloudfile_ext/v2/rust_edit.py index 3f107ef11e7..90427a23f71 100644 --- a/cloudfile_ext/v2/rust_edit.py +++ b/cloudfile_ext/v2/rust_edit.py @@ -200,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, @@ -209,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, From 96b086dd30d154cabe338e7e539ccbc0198d5c16 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Sun, 25 Oct 2026 12:18:02 +0800 Subject: [PATCH 8/8] test(checkout): make lock-info test users throttle-safe A real Django environment runs UserRateThrottle before the view; the synthetic user needs an id. Verified all 11 rust_edit tests pass against the devverify image (throttle bypassed only because the standalone container has no Redis). --- cloudfile_ext/v2/tests/test_rust_edit_view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudfile_ext/v2/tests/test_rust_edit_view.py b/cloudfile_ext/v2/tests/test_rust_edit_view.py index bb01f415fb3..7a4a708fcfb 100644 --- a/cloudfile_ext/v2/tests/test_rust_edit_view.py +++ b/cloudfile_ext/v2/tests/test_rust_edit_view.py @@ -22,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) @@ -107,7 +107,7 @@ def test_malformed_selection_type_fails_safe_to_off(self): 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(username='reader@auth.local', + 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.