Skip to content
3 changes: 3 additions & 0 deletions cloudfile_ext/settings_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 16 additions & 5 deletions cloudfile_ext/v2/rust_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("/"))):
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
50 changes: 47 additions & 3 deletions cloudfile_ext/v2/tests/test_rust_edit_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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, \
Expand Down Expand Up @@ -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:
Expand Down
61 changes: 61 additions & 0 deletions docs/checkout-per-repo-policy.md
Original file line number Diff line number Diff line change
@@ -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.
Loading