From ac8e60a561dc40f979614cc6e8a2060161ab2e80 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Sun, 26 Jul 2026 14:39:00 +0800 Subject: [PATCH 1/4] feat(fileop): add the unified write lifecycle extension point CE has nowhere to ask the two questions the write side needs: may this write happen, and what happened. cf-ext.h only has read-side seams, and Hub's register_file_op_hook has no upstream caller. Six capabilities were blocked on that -- file lock, checkout, OnlyOffice write-back, properties, tags, and metadata following a rename. Building the lock first would have produced two of these: the lock's veto point and the file_op event source, covering the same write entry points. So the contract comes first and the lock registers into it. common/cf-fileop.{c,h} defines PREPARE (first refusal wins, nothing persisted yet), COMMITTED (immutable, exactly once per successful operation) and ABORTED (best effort -- a provider needing reserve/release must carry its own lease, which is why the lock is built on heartbeats). Both it and common/cf-path.c depend on nothing but glib, so the vocabulary, the path rules and the dispatcher all compile into a standalone test. The seam goes in server/repo-op.c, not the already-registered rpc-service.c: upload-file.c, the virtual repo merge and copy-mgr all reach seaf_repo_manager_* directly, and an adjudication point with a way around it is not one. That takes the upstream patch count from 33 to 35. seafdav needs no patch -- its writes go through seafile_api.* into repo-op.c, so it inherits the C answer. A second Python implementation would only be a second thing to drift. The Go fileserver does need its own seam, because it chunks, writes objects, commits and updates the branch without entering C. It asks over RPC rather than reimplementing. Unlike cf_ext.go it never caches the verdict: caching "no provider registered" would leave a window, after an operator enables a capability and restarts, in which every upload bypasses it. What is cached is only which of three worlds we are in, to pick the failure mode -- fail open with nothing registered, fail closed with something registered. Path normalization moves from cf-acl-resolve.c down to common/cf-path.c with a forwarder left behind. The ACL keys rules by path and the lock keys leases by path; if they normalized differently, a rule on /a/b and a lock on /a/b/ would be about different objects. Evidence: 144 C checks, 6 Go cross-language contract checks, 50 call sites type-checked against the real header, 9 mutations all caught. None of that proves the seam is reached at runtime -- that needs the Linux stack, and the fake-provider veto matrix is not written yet. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 43 +- common/cf-acl-resolve.c | 35 +- common/cf-fileop-json.c | 131 +++++++ common/cf-fileop-json.h | 36 ++ common/cf-fileop.c | 246 ++++++++++++ common/cf-fileop.h | 239 +++++++++++ common/cf-path.c | 49 +++ common/cf-path.h | 53 +++ common/rpc-service.c | 124 ++++++ fileserver/cf_fileop.go | 276 +++++++++++++ fileserver/cf_fileop_test.go | 267 +++++++++++++ fileserver/fileop.go | 125 +++++- fileserver/sync_api.go | 21 + include/seafile-rpc.h | 26 ++ python/seafile/rpcclient.py | 23 ++ python/seaserv/api.py | 12 + server/Makefile.am | 6 + server/repo-op.c | 589 ++++++++++++++++++++++++++-- server/seaf-server.c | 21 + tests/cf-acl/run.sh | 5 + tests/cf-fileop/check-call-sites.py | 233 +++++++++++ tests/cf-fileop/gen-cases.py | 112 ++++++ tests/cf-fileop/run.sh | 44 +++ tests/cf-fileop/test-cf-fileop.c | 376 ++++++++++++++++++ 24 files changed, 3032 insertions(+), 60 deletions(-) create mode 100644 common/cf-fileop-json.c create mode 100644 common/cf-fileop-json.h create mode 100644 common/cf-fileop.c create mode 100644 common/cf-fileop.h create mode 100644 common/cf-path.c create mode 100644 common/cf-path.h create mode 100644 fileserver/cf_fileop.go create mode 100644 fileserver/cf_fileop_test.go create mode 100755 tests/cf-fileop/check-call-sites.py create mode 100755 tests/cf-fileop/gen-cases.py create mode 100755 tests/cf-fileop/run.sh create mode 100644 tests/cf-fileop/test-cf-fileop.c diff --git a/AGENTS.md b/AGENTS.md index decfe51a..89c78468 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,16 +50,23 @@ workspace/ | 文件 | 改了什么 | |---|---| -| `common/rpc-service.c` | `check_permission_by_path` 与目录列举接入扩展点,新增 `cf_find_restricted_path` RPC | +| `common/rpc-service.c` | `check_permission_by_path` 与目录列举接入扩展点,新增 `cf_find_restricted_path` 与 `cf_fileop_*` RPC | | `include/seafile-rpc.h` | 新 RPC 声明 | | `server/seaf-server.c` | 新 RPC 注册 | | `server/seafile-session.c` | 启动时 `cf_ext_init()` | | `server/Makefile.am` | 新增源文件 | -| `fileserver/sync_api.go` | 同步前的子树校验(两处) | +| `server/repo-op.c` | 写入生命周期扩展点:19 个写入口各发 PREPARE / COMMITTED / ABORTED | +| `fileserver/sync_api.go` | 同步前的子树校验(两处)+ `sync-update` 生命周期 | +| `fileserver/fileop.go` | Go 侧写入口的生命周期接入 | | `python/seaserv/api.py` | `is_repo_syncable` / `is_dir_downloadable` 透传 RPC | | `python/seafile/rpcclient.py` | 新 RPC 客户端声明 | -**这 8 个是基线一次性付掉的代价,能力分支不应再增加。** +**这 10 个是基线一次性付掉的代价,能力分支不应再增加。** + +后两个(`repo-op.c`、`fileop.go`)是写入生命周期扩展点带来的,理由写在 +`cloudfile-docker/docs/fileop-lifecycle.md` 第五节:seam 不能放在已经登记过的 +`rpc-service.c`,因为 `upload-file.c`、虚拟库合并和 `copy-mgr` 都直接调用 +`seaf_repo_manager_*`,绕过 RPC 层——**终判点不能有绕行路**。 改动这份清单时,同步更新 `cloudfile-docker/BRANCHING.md`——那是同步上游时的 检查依据,失真就会漏掉冲突点。 @@ -71,12 +78,20 @@ docker 仓的 bootstrap 找不到该文件时会跳过并告警。 ## 扩展点:cf-ext ``` -common/cf-ext.{c,h} 扩展点本身:配置读取 + 能力注册表 + 三个分发钩子 -fileserver/cf_ext.go 同步客户端网关,走 RPC 问 seaf-server +common/cf-ext.{c,h} 读侧扩展点:配置读取 + 能力注册表 + 三个分发钩子 +common/cf-fileop.{c,h} 写侧扩展点:PREPARE / COMMITTED / ABORTED +common/cf-fileop-json.{c,h} 上面那个的 JSON 线格式(jansson 只出现在这里) +common/cf-path.{c,h} 路径规范化,两个扩展点共用同一份 +fileserver/cf_ext.go 同步客户端网关,走 RPC 问 seaf-server +fileserver/cf_fileop.go Go 写入口网关,同样走 RPC 问 seaf-server ``` -`cf_ext_init()` 里没有注册任何能力,所以基线上每个钩子都是透传,行为与原生 CE -完全一致。 +`cf_ext_init()` 里没有注册任何能力,`cf_fileop_register()` 也没人调用,所以基线上 +每个钩子都是透传,行为与原生 CE 完全一致。 + +`cf-fileop.c`、`cf-path.c` 刻意只依赖 glib,因此 `tests/cf-fileop/run.sh` 不需要 +完整的 seafile 构建就能跑——与 `cf-acl-resolve.c` 同一条理由。规格见 +`cloudfile-docker/docs/fileop-lifecycle.md`。 **为什么用注册表而不是直接调用某个能力:** @@ -106,7 +121,19 @@ fileserver/cf_ext.go 同步客户端网关,走 RPC 问 seaf-server ## 测试 基线没有能力实现,因此没有能力级测试;`tests/` 下的测试随能力分支一起走 -(例如 `feature/dir-acl` 的 `tests/cf-acl/run.sh`)。 +(例如 `feature/dir-acl` 的 `tests/cf-acl/run.sh`)。**例外是扩展点自己**: +`tests/cf-fileop/run.sh` 属于基线,它测的是 seam 而不是某个能力,包括 +"没有 provider 时什么都不做"这条铁律——一个从不被断言的不变量迟早会被违反。 + +```bash +./tests/cf-fileop/run.sh +``` + +它还顺带做一件本机做不到的事的近似:`check-call-sites.py` 把 `repo-op.c` 里每个 +`CF_FILEOP_*` 调用抽出来、把值换成对应类型的哑变量、再拿真正的 `cf-fileop.h` +编译一遍。`repo-op.c` 在 macOS 上编译不了,而拼错字段名、写错 operation、 +少个逗号这类错误本来要等 CI 二十分钟才暴露。它**不**检查传的变量对不对—— +`.name = parent_dir` 类型是对的,值是错的,那只能靠 review 和 E2E。 Go 部分: diff --git a/common/cf-acl-resolve.c b/common/cf-acl-resolve.c index ddfde07f..650b909b 100644 --- a/common/cf-acl-resolve.c +++ b/common/cf-acl-resolve.c @@ -3,6 +3,7 @@ #include #include "cf-acl-resolve.h" +#include "cf-path.h" CfAclRule * cf_acl_rule_new (const char *path, @@ -77,36 +78,18 @@ cf_acl_subject_key (int subject_type, const char *subject) } /* - * Collapse separators, force a leading slash, strip the trailing one. - * Deliberately leaves case and Unicode composition alone: Seafile paths are - * byte-sensitive, and folding them here would let two distinct directories - * share one ACL entry. + * Moved to common/cf-path.c when the write lifecycle seam needed the same + * rules: the ACL keys rules by path and the lock keys leases by path, and if + * the two normalized differently a rule on /a/b and a lock on /a/b/ would be + * about different objects. One implementation, so there is nothing to drift. + * + * Kept as a forwarder rather than renaming the call sites so the ACL's own + * tests and case set stayed untouched by a baseline refactor. */ char * cf_acl_normalize_path (const char *path) { - if (!path || *path == '\0') - return g_strdup ("/"); - - GString *buf = g_string_new (""); - const char *p = path; - - while (*p) { - while (*p == '/') - p++; - if (!*p) - break; - const char *start = p; - while (*p && *p != '/') - p++; - g_string_append_c (buf, '/'); - g_string_append_len (buf, start, p - start); - } - - if (buf->len == 0) - g_string_append_c (buf, '/'); - - return g_string_free (buf, FALSE); + return cf_path_normalize (path); } GList * diff --git a/common/cf-fileop-json.c b/common/cf-fileop-json.c new file mode 100644 index 00000000..d30edfa6 --- /dev/null +++ b/common/cf-fileop-json.c @@ -0,0 +1,131 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +#include +#include + +#include "cf-fileop-json.h" +#include "log.h" +#include "seafile-error.h" + +/* Returns an owned copy of a string member, or NULL when absent, null or the + * empty string. Empty and absent are the same thing here: the Go side omits + * nothing, so "" is how it spells "not applicable". */ +static char * +dup_string_member (json_t *obj, const char *key) +{ + json_t *value = json_object_get (obj, key); + if (!value || !json_is_string (value)) + return NULL; + + const char *str = json_string_value (value); + if (!str || *str == '\0') + return NULL; + + return g_strdup (str); +} + +static GList * +dup_string_array (json_t *obj, const char *key) +{ + json_t *array = json_object_get (obj, key); + if (!array || !json_is_array (array)) + return NULL; + + GList *list = NULL; + size_t i; + json_t *value; + + json_array_foreach (array, i, value) { + if (!json_is_string (value)) + continue; + list = g_list_append (list, g_strdup (json_string_value (value))); + } + + return list; +} + +CfFileOp * +cf_fileop_from_json (const char *json, GError **error) +{ + json_error_t jerror; + json_t *obj = NULL; + CfFileOp *fop = NULL; + char *op = NULL; + + if (!json) { + g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_BAD_ARGS, + "Empty file operation"); + return NULL; + } + + obj = json_loadb (json, strlen (json), 0, &jerror); + if (!obj || !json_is_object (obj)) { + seaf_warning ("CloudFile: bad file operation payload: %s\n", jerror.text); + g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_BAD_ARGS, + "Malformed file operation"); + if (obj) + json_decref (obj); + return NULL; + } + + op = dup_string_member (obj, "op"); + + /* Rejecting an unrecognised operation here rather than letting the + * dispatcher see it keeps the failure at the edge, where the payload is + * still available to log. Same reasoning as the dispatcher's own check: + * an operation nobody recognises must not sail past every provider. */ + if (!cf_fileop_op_valid (op)) { + seaf_warning ("CloudFile: unknown file operation '%s' from fileserver.\n", + op ? op : "(null)"); + g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_BAD_ARGS, + "Unknown file operation"); + g_free (op); + json_decref (obj); + return NULL; + } + + fop = g_new0 (CfFileOp, 1); + fop->op = op; + fop->repo_id = dup_string_member (obj, "repo_id"); + fop->dir = dup_string_member (obj, "dir"); + fop->name = dup_string_member (obj, "name"); + fop->names = dup_string_array (obj, "names"); + fop->src_repo_id = dup_string_member (obj, "src_repo_id"); + fop->src_dir = dup_string_member (obj, "src_dir"); + fop->src_name = dup_string_member (obj, "src_name"); + fop->src_names = dup_string_array (obj, "src_names"); + fop->user = dup_string_member (obj, "user"); + fop->client = dup_string_member (obj, "client"); + fop->expect_commit_id = dup_string_member (obj, "expect_commit_id"); + fop->commit_id = dup_string_member (obj, "commit_id"); + fop->file_id = dup_string_member (obj, "file_id"); + + json_decref (obj); + + return fop; +} + +void +cf_fileop_json_free (CfFileOp *fop) +{ + if (!fop) + return; + + /* The struct declares these const because providers must not rewrite + * them; ownership is still ours for a parsed context. */ + g_free ((char *)fop->op); + g_free ((char *)fop->repo_id); + g_free ((char *)fop->dir); + g_free ((char *)fop->name); + g_list_free_full (fop->names, g_free); + g_free ((char *)fop->src_repo_id); + g_free ((char *)fop->src_dir); + g_free ((char *)fop->src_name); + g_list_free_full (fop->src_names, g_free); + g_free ((char *)fop->user); + g_free ((char *)fop->client); + g_free ((char *)fop->expect_commit_id); + g_free ((char *)fop->commit_id); + g_free ((char *)fop->file_id); + g_free (fop); +} diff --git a/common/cf-fileop-json.h b/common/cf-fileop-json.h new file mode 100644 index 00000000..2140156d --- /dev/null +++ b/common/cf-fileop-json.h @@ -0,0 +1,36 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +/* + * JSON wire form of CfFileOp, for the Go fileserver. + * + * Split from cf-fileop.c so that file keeps depending on nothing but glib and + * still compiles into the standalone test binary. jansson lives only here. + * + * One string argument rather than a fixed-arity searpc signature: the context + * has thirteen optional fields and P1 adds session identity to it. Widening a + * searpc signature means touching the registration, the client stub and every + * caller; widening a JSON object means neither side has to move in lockstep, + * and an older peer simply does not send the new key. + * + * Wire format: cloudfile-docker/docs/fileop-lifecycle.md section 4. + */ + +#ifndef CF_FILEOP_JSON_H +#define CF_FILEOP_JSON_H + +#include + +#include "cf-fileop.h" + +/* + * Parse @json into a heap CfFileOp whose string fields are owned copies. + * Returns NULL and sets @error on malformed input or an operation outside the + * vocabulary. + * + * Free with cf_fileop_json_free(). + */ +CfFileOp *cf_fileop_from_json (const char *json, GError **error); + +void cf_fileop_json_free (CfFileOp *fop); + +#endif /* CF_FILEOP_JSON_H */ diff --git a/common/cf-fileop.c b/common/cf-fileop.c new file mode 100644 index 00000000..ae42406d --- /dev/null +++ b/common/cf-fileop.c @@ -0,0 +1,246 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +#include + +#include "cf-fileop.h" +#include "cf-path.h" +#include "log.h" +#include "seafile-error.h" + +/* ------------------------------------------------------------- vocabulary */ + +typedef struct OpSpec { + const char *op; + gboolean has_source; + gboolean pathless; + gboolean subject_is_root; +} OpSpec; + +static const OpSpec op_specs[] = { + { CF_OP_CREATE_FILE, FALSE, FALSE, FALSE }, + { CF_OP_UPDATE_FILE, FALSE, FALSE, FALSE }, + { CF_OP_DELETE, FALSE, FALSE, FALSE }, + { CF_OP_MKDIR, FALSE, FALSE, FALSE }, + { CF_OP_RENAME, TRUE, FALSE, FALSE }, + { CF_OP_MOVE, TRUE, FALSE, FALSE }, + { CF_OP_COPY, TRUE, FALSE, FALSE }, + { CF_OP_REVERT_FILE, FALSE, FALSE, FALSE }, + { CF_OP_REVERT_DIR, FALSE, FALSE, FALSE }, + { CF_OP_REVERT_REPO, FALSE, FALSE, TRUE }, + { CF_OP_UPDATE_DIR, FALSE, FALSE, FALSE }, + { CF_OP_UPLOAD_BLOCKS, FALSE, TRUE, FALSE }, + { CF_OP_SYNC_UPDATE, FALSE, FALSE, TRUE }, +}; + +static const OpSpec * +find_op (const char *op) +{ + if (!op) + return NULL; + + for (guint i = 0; i < G_N_ELEMENTS (op_specs); i++) { + if (strcmp (op_specs[i].op, op) == 0) + return &op_specs[i]; + } + + return NULL; +} + +gboolean +cf_fileop_op_valid (const char *op) +{ + return find_op (op) != NULL; +} + +gboolean +cf_fileop_op_has_source (const char *op) +{ + const OpSpec *spec = find_op (op); + return spec ? spec->has_source : FALSE; +} + +gboolean +cf_fileop_op_pathless (const char *op) +{ + const OpSpec *spec = find_op (op); + return spec ? spec->pathless : FALSE; +} + +gboolean +cf_fileop_op_subject_is_root (const char *op) +{ + const OpSpec *spec = find_op (op); + return spec ? spec->subject_is_root : FALSE; +} + +/* ---------------------------------------------------------------- context */ + +char * +cf_fileop_subject_path (const CfFileOp *fop) +{ + if (!fop || cf_fileop_op_pathless (fop->op)) + return NULL; + + if (cf_fileop_op_subject_is_root (fop->op)) + return g_strdup ("/"); + + return cf_path_join (fop->dir, fop->name); +} + +GList * +cf_fileop_subject_paths (const CfFileOp *fop) +{ + if (!fop || cf_fileop_op_pathless (fop->op)) + return NULL; + + if (!fop->names) + return g_list_append (NULL, cf_fileop_subject_path (fop)); + + GList *paths = NULL, *ptr; + + for (ptr = fop->names; ptr; ptr = ptr->next) + paths = g_list_append (paths, cf_path_join (fop->dir, ptr->data)); + + return paths; +} + +GList * +cf_fileop_source_paths (const CfFileOp *fop) +{ + if (!fop || !cf_fileop_op_has_source (fop->op)) + return NULL; + + if (!fop->src_names) + return g_list_append (NULL, cf_path_join (fop->src_dir, fop->src_name)); + + GList *paths = NULL, *ptr; + + for (ptr = fop->src_names; ptr; ptr = ptr->next) + paths = g_list_append (paths, cf_path_join (fop->src_dir, ptr->data)); + + return paths; +} + +/* ------------------------------------------------------------- providers */ + +typedef struct Provider { + char *name; + CfFileOpPrepareFunc prepare; + CfFileOpCommittedFunc committed; + CfFileOpAbortedFunc aborted; +} Provider; + +static GList *providers = NULL; /* Provider*, registration order */ + +void +cf_fileop_register (const char *name, + CfFileOpPrepareFunc prepare, + CfFileOpCommittedFunc committed, + CfFileOpAbortedFunc aborted) +{ + Provider *p = g_new0 (Provider, 1); + p->name = g_strdup (name); + p->prepare = prepare; + p->committed = committed; + p->aborted = aborted; + + providers = g_list_append (providers, p); + seaf_message ("CloudFile: write lifecycle provider '%s' registered.\n", + name); +} + +static void +provider_free (void *data) +{ + Provider *p = data; + g_free (p->name); + g_free (p); +} + +void +cf_fileop_reset (void) +{ + g_list_free_full (providers, provider_free); + providers = NULL; +} + +gboolean +cf_fileop_active (void) +{ + return providers != NULL; +} + +/* ------------------------------------------------------------- dispatch */ + +int +cf_fileop_prepare (CfFileOp *fop, GError **error) +{ + if (!providers) + return 0; + + /* An operation string outside the vocabulary means a call site and this + * table disagree. Refusing is the only safe answer: the alternative is to + * let a write past every provider because nobody recognised it, which is + * fail-open in the one place that must not be. + */ + if (!cf_fileop_op_valid (fop->op)) { + seaf_warning ("CloudFile: unknown file operation '%s'.\n", + fop->op ? fop->op : "(null)"); + g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_GENERAL, + "Unknown file operation"); + return -1; + } + + fop->phase = CF_FILEOP_PHASE_PREPARE; + + GList *ptr; + for (ptr = providers; ptr; ptr = ptr->next) { + Provider *p = ptr->data; + if (!p->prepare) + continue; + + if (p->prepare (fop, error) < 0) { + /* Stop here. Later providers must not observe a write that is not + * going to happen. + */ + if (error && !*error) + g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_GENERAL, + "Refused by CloudFile provider %s", p->name); + return -1; + } + } + + return 0; +} + +void +cf_fileop_committed (CfFileOp *fop) +{ + if (!providers) + return; + + fop->phase = CF_FILEOP_PHASE_COMMITTED; + + GList *ptr; + for (ptr = providers; ptr; ptr = ptr->next) { + Provider *p = ptr->data; + if (p->committed) + p->committed (fop); + } +} + +void +cf_fileop_aborted (CfFileOp *fop) +{ + if (!providers) + return; + + fop->phase = CF_FILEOP_PHASE_ABORTED; + + GList *ptr; + for (ptr = providers; ptr; ptr = ptr->next) { + Provider *p = ptr->data; + if (p->aborted) + p->aborted (fop); + } +} diff --git a/common/cf-fileop.h b/common/cf-fileop.h new file mode 100644 index 00000000..a7a3ab88 --- /dev/null +++ b/common/cf-fileop.h @@ -0,0 +1,239 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +/* + * CloudFile write lifecycle extension point. + * + * The read-side seams in cf-ext.h answer "may this user see it". This one + * answers the two questions the write side needs and CE has nowhere to ask: + * + * PREPARE -- may this write happen? A provider may refuse, and nothing + * has been persisted yet when it does. + * COMMITTED -- it happened. An immutable fact, exactly once per successful + * operation. + * ABORTED -- it did not happen after PREPARE allowed it. Best effort. + * + * Six capabilities were blocked on this: file lock, checkout, OnlyOffice + * write-back, file properties, tags, and metadata following a rename. Building + * the lock first would have produced two of these -- the lock's veto point and + * the file_op event source -- covering the same set of write entry points. So + * the contract comes first and the lock registers into it. + * + * Everything here depends on nothing but glib, so the vocabulary, the path + * rules and the dispatcher all compile into tests/cf-fileop without the + * seafile build. Providers supply their own I/O. + * + * Spec: cloudfile-docker/docs/fileop-lifecycle.md + * Cases: cloudfile-docker/docs/fileop-cases.json + */ + +#ifndef CF_FILEOP_H +#define CF_FILEOP_H + +#include + +/* ------------------------------------------------------------- vocabulary */ + +/* + * Stable strings, not an enum: these cross C, Go, searpc and eventually + * Python. An ordinal that shifts in one place is a silent semantic error; + * a string that shifts fails the vocabulary check loudly. + */ +#define CF_OP_CREATE_FILE "create-file" +#define CF_OP_UPDATE_FILE "update-file" +#define CF_OP_DELETE "delete" +#define CF_OP_MKDIR "mkdir" +#define CF_OP_RENAME "rename" +#define CF_OP_MOVE "move" +#define CF_OP_COPY "copy" +#define CF_OP_REVERT_FILE "revert-file" +#define CF_OP_REVERT_DIR "revert-dir" +#define CF_OP_REVERT_REPO "revert-repo" +#define CF_OP_UPDATE_DIR "update-dir" +#define CF_OP_UPLOAD_BLOCKS "upload-blocks" +#define CF_OP_SYNC_UPDATE "sync-update" + +/* Whether @op is in the vocabulary above. */ +gboolean cf_fileop_op_valid (const char *op); + +/* Whether @op carries src_repo_id / src_path. */ +gboolean cf_fileop_op_has_source (const char *op); + +/* + * Whether @op has no object path at all. Only upload-blocks: it writes blocks + * into the object store without touching any directory tree. + * + * A lock provider MUST ignore these. With no path there is no lock subject, so + * refusing here refuses at random; the real adjudication happens in the + * create-file or update-file that follows. + */ +gboolean cf_fileop_op_pathless (const char *op); + +/* Whether @op's subject is the whole library rather than one path. */ +gboolean cf_fileop_op_subject_is_root (const char *op); + +/* ----------------------------------------------------------- error codes */ + +/* + * CloudFile's own codes, set into SEAFILE_DOMAIN alongside the upstream ones. + * + * Deliberately based at 600 rather than added to include/seafile-error.h: + * that is an upstream file we have not had to patch, and the fork cost of + * touching one more upstream file is paid at every sync, forever. Upstream is + * at 522, so 600 leaves it room to grow into. + */ +#define CF_ERR_FILE_LOCKED 600 /* -> 423 */ +#define CF_ERR_VERSION_MISMATCH 601 /* -> 409 */ + +/* ---------------------------------------------------------------- context */ + +typedef enum { + CF_FILEOP_PHASE_PREPARE = 0, + CF_FILEOP_PHASE_COMMITTED, + CF_FILEOP_PHASE_ABORTED, +} CfFileOpPhase; + +/* + * What a provider is told about a write. Read-only: a provider may refuse, + * never rewrite. If providers could rewrite, their registration order would + * decide the result, and nobody designed that order. + * + * Call sites fill the raw pieces; the dispatcher joins and normalizes. That + * ordering is what keeps an inactive build free: nothing is allocated until + * after the active check. + */ +typedef struct CfFileOp { + CfFileOpPhase phase; /* set by the dispatcher */ + const char *op; + + const char *repo_id; + const char *dir; /* parent dir, or the object itself */ + const char *name; /* entry under @dir; NULL when @dir is it */ + GList *names; /* batch: char* entry names under @dir */ + + /* + * A move writes both ends: the entry leaves the source directory. So the + * source is not merely informational for move and rename -- a lock on the + * source must refuse it. Batch moves need the list for the same reason + * the destination does. + */ + const char *src_repo_id; + const char *src_dir; + const char *src_name; + GList *src_names; + + const char *user; /* Seafile identity, NOT an email address */ + const char *client; /* session identity; NULL until P1 */ + const char *expect_commit_id; + + /* COMMITTED only. */ + const char *commit_id; + const char *file_id; +} CfFileOp; + +/* + * The normalized object path of @fop: cf_path_join(dir, name). + * Returns a newly allocated string; caller frees. NULL for pathless ops. + */ +char *cf_fileop_subject_path (const CfFileOp *fop); + +/* + * Every normalized object path of @fop. One element for a single-object + * operation, one per entry for a batch. Free with + * g_list_free_full(list, g_free). NULL for pathless ops. + */ +GList *cf_fileop_subject_paths (const CfFileOp *fop); + +/* + * Every normalized source path of @fop, for the ops that have one. NULL when + * @op carries no source. Free with g_list_free_full(list, g_free). + */ +GList *cf_fileop_source_paths (const CfFileOp *fop); + +/* ------------------------------------------------------------- providers */ + +/* + * Refuse by returning -1 and setting @error; the code reaches the client, so + * the message must explain why (who holds the lock, how long is left) rather + * than just "permission denied". + * + * @fop is borrowed and must not be modified or retained. + */ +typedef int (*CfFileOpPrepareFunc) (const CfFileOp *fop, GError **error); + +/* + * The write happened. Cannot fail the operation -- the file has already + * changed, and returning an error here would make the client retry something + * that already took effect. Log and move on. + */ +typedef void (*CfFileOpCommittedFunc) (const CfFileOp *fop); + +/* + * PREPARE allowed it and it did not happen. Best effort: no ABORTED arrives + * after a crash. A provider needing reserve/release semantics must carry its + * own lease and timeout instead of relying on this -- which is exactly why + * the file lock is built on heartbeats and lease_until. + */ +typedef void (*CfFileOpAbortedFunc) (const CfFileOp *fop); + +/* Register a capability. @name is for logging. Any function may be NULL. */ +void cf_fileop_register (const char *name, + CfFileOpPrepareFunc prepare, + CfFileOpCommittedFunc committed, + CfFileOpAbortedFunc aborted); + +/* Drop every provider. Tests only; the server never unregisters. */ +void cf_fileop_reset (void); + +/* + * Whether any provider is registered. + * + * Every call site is guarded on this, so a build with no capability does one + * global read and returns: no context is built, no path is normalized, no + * query is made, and no return code changes. + */ +gboolean cf_fileop_active (void); + +/* ------------------------------------------------------------- dispatch */ + +/* + * Run @fop past every provider in registration order and stop at the first + * refusal. Returns 0 to allow, -1 to refuse with @error set. + * + * Providers after a refusal do not run: the write is not going to happen, so + * letting them observe it would make a refused operation have side effects. + */ +int cf_fileop_prepare (CfFileOp *fop, GError **error); + +void cf_fileop_committed (CfFileOp *fop); +void cf_fileop_aborted (CfFileOp *fop); + +/* + * Call-site sugar. The guard lives inside the macro so the compound literal is + * never constructed on an inactive build, and so a call site stays one + * statement instead of five. + * + * if (CF_FILEOP_PREPARE (CF_OP_CREATE_FILE, error, + * .repo_id = repo_id, .dir = canon_path, + * .name = file_name, .user = user) < 0) { + * ret = -1; + * goto out; + * } + */ +#define CF_FILEOP_PREPARE(_op, _error, ...) \ + (cf_fileop_active () \ + ? cf_fileop_prepare (&(CfFileOp){ .op = (_op), __VA_ARGS__ }, (_error))\ + : 0) + +#define CF_FILEOP_COMMITTED(_op, ...) \ + do { \ + if (cf_fileop_active ()) \ + cf_fileop_committed (&(CfFileOp){ .op = (_op), __VA_ARGS__ }); \ + } while (0) + +#define CF_FILEOP_ABORTED(_op, ...) \ + do { \ + if (cf_fileop_active ()) \ + cf_fileop_aborted (&(CfFileOp){ .op = (_op), __VA_ARGS__ }); \ + } while (0) + +#endif /* CF_FILEOP_H */ diff --git a/common/cf-path.c b/common/cf-path.c new file mode 100644 index 00000000..33ff0b01 --- /dev/null +++ b/common/cf-path.c @@ -0,0 +1,49 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +#include + +#include "cf-path.h" + +char * +cf_path_normalize (const char *path) +{ + if (!path || *path == '\0') + return g_strdup ("/"); + + GString *buf = g_string_new (""); + const char *p = path; + + while (*p) { + while (*p == '/') + p++; + if (!*p) + break; + const char *start = p; + while (*p && *p != '/') + p++; + g_string_append_c (buf, '/'); + g_string_append_len (buf, start, p - start); + } + + if (buf->len == 0) + g_string_append_c (buf, '/'); + + return g_string_free (buf, FALSE); +} + +char * +cf_path_join (const char *dir, const char *entry) +{ + if (!entry || *entry == '\0') + return cf_path_normalize (dir); + + /* Normalizing the concatenation rather than the two halves separately is + * what makes ("/a", "/b//c") behave: the separator run in the middle is + * just another run to collapse. + */ + char *joined = g_strconcat (dir ? dir : "", "/", entry, NULL); + char *norm = cf_path_normalize (joined); + g_free (joined); + + return norm; +} diff --git a/common/cf-path.h b/common/cf-path.h new file mode 100644 index 00000000..f3069681 --- /dev/null +++ b/common/cf-path.h @@ -0,0 +1,53 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +/* + * CloudFile path normalization -- baseline, no I/O. + * + * This lived in cf-acl-resolve.c until the write lifecycle seam needed it + * too. Path normalization is a framework-level fact, not the directory ACL's: + * ACL keys rules by path, the lock keys leases by path, and the two MUST agree + * byte for byte or a lock on /a/b/ and a rule on /a/b are about different + * objects. Leaving it inside a capability would also mean the baseline seam + * imports a capability at runtime, and that the ACL switch decides whether + * paths can be normalized at all. + * + * Same move, same reasoning as acl/subjects.py -> cloudfile_ext/identity.py on + * the Hub side (FEATURES.md item 79). cf_acl_normalize_path() stays as a thin + * forwarder so the ACL's own tests did not have to change. + * + * Depends on nothing but glib, so it compiles into the standalone test + * binaries alongside the pure-policy halves of the capabilities. + * + * Rules: cloudfile-docker/docs/fileop-lifecycle.md section 4. + */ + +#ifndef CF_PATH_H +#define CF_PATH_H + +#include + +/* + * Collapse separators, force a leading slash, strip the trailing one. The + * empty path and NULL both normalize to "/". + * + * Deliberately leaves case and Unicode composition alone: Seafile paths are + * byte-sensitive, and folding them here would let two distinct directories + * share one entry. + * + * Returns a newly allocated string; caller frees. + */ +char *cf_path_normalize (const char *path); + +/* + * Normalized @dir with @entry appended. @entry may itself carry separators or + * a leading slash; it is joined and then normalized as a whole, so + * ("/a", "/b/c") and ("/a/", "b//c") both give "/a/b/c". + * + * A NULL or empty @entry means @dir is itself the object -- that is the mkdir, + * revert-dir and update-dir shape, where there is no entry name to append. + * + * Returns a newly allocated string; caller frees. + */ +char *cf_path_join (const char *dir, const char *entry); + +#endif /* CF_PATH_H */ diff --git a/common/rpc-service.c b/common/rpc-service.c index acffad03..efc0229e 100644 --- a/common/rpc-service.c +++ b/common/rpc-service.c @@ -23,6 +23,8 @@ * seaf->db, seaf->group_mgr and seaf->cfg_mgr, none of which exist in a * non-server build. */ #include "cf-ext.h" +#include "cf-fileop.h" +#include "cf-fileop-json.h" #endif #ifndef SEAFILE_SERVER @@ -4120,6 +4122,128 @@ seafile_cf_find_restricted_path (const char *repo_id, const char *path, #endif } +/* + * CloudFile write lifecycle, exposed for the Go fileserver. + * + * The Go fileserver chunks, writes objects, generates commits and updates the + * branch without ever entering repo-op.c, so it is the one write path the C + * seam cannot see. Rather than reimplement the adjudication in Go -- a second + * implementation is a second thing to drift -- it asks over these RPCs and C + * stays the single authority. Same shape as cf_find_restricted_path. + * + * The context travels as one JSON string; see cf-fileop-json.h for why. + */ +int +seafile_cf_fileop_active (GError **error) +{ +#ifdef SEAFILE_SERVER + return cf_fileop_active () ? 1 : 0; +#else + return 0; +#endif +} + +/* + * Returns a JSON verdict rather than raising, and the reason is the same one + * seafile_cf_find_restricted_path gives for swallowing its inner error: a + * refusal is a normal answer, not an RPC failure. Two things force it here: + * + * - The Go searpc client discards err_code and keeps only err_msg, so a + * refusal raised as a GError would arrive with its 423-vs-403 distinction + * already gone -- and recovering it would mean patching searpc.go, one + * more upstream file to carry forever. + * - An RPC-level error is indistinguishable from the server being broken, + * and "the file is locked" must not read as "the server is down". + * + * Shape: {"allowed":true} or {"allowed":false,"code":423,"message":"..."}. + * Returns NULL only for a genuinely malformed payload, which IS a failure. + */ +char * +seafile_cf_fileop_prepare (const char *fop_json, GError **error) +{ +#ifdef SEAFILE_SERVER + if (!cf_fileop_active ()) + return g_strdup ("{\"allowed\":true}"); + + CfFileOp *fop = cf_fileop_from_json (fop_json, error); + if (!fop) + return NULL; + + GError *refusal = NULL; + int ret = cf_fileop_prepare (fop, &refusal); + + cf_fileop_json_free (fop); + + if (ret == 0) { + g_clear_error (&refusal); + return g_strdup ("{\"allowed\":true}"); + } + + json_t *verdict = json_object (); + json_object_set_new (verdict, "allowed", json_false ()); + json_object_set_new (verdict, "code", + json_integer (refusal ? refusal->code : SEAF_ERR_GENERAL)); + json_object_set_new (verdict, "message", + json_string (refusal && refusal->message + ? refusal->message : "Refused")); + + char *out = json_dumps (verdict, JSON_COMPACT); + json_decref (verdict); + g_clear_error (&refusal); + + /* json_dumps uses malloc; hand back a glib allocation so the caller frees + * it the same way as every other RPC string. */ + char *ret_str = g_strdup (out ? out : "{\"allowed\":false,\"code\":500}"); + free (out); + + return ret_str; +#else + return g_strdup ("{\"allowed\":true}"); +#endif +} + +int +seafile_cf_fileop_committed (const char *fop_json, GError **error) +{ +#ifdef SEAFILE_SERVER + if (!cf_fileop_active ()) + return 0; + + CfFileOp *fop = cf_fileop_from_json (fop_json, error); + if (!fop) + return -1; + + cf_fileop_committed (fop); + cf_fileop_json_free (fop); + + /* Always success: the write already happened. Reporting a failure here + * would make the fileserver retry an operation that took effect. */ + return 0; +#else + return 0; +#endif +} + +int +seafile_cf_fileop_aborted (const char *fop_json, GError **error) +{ +#ifdef SEAFILE_SERVER + if (!cf_fileop_active ()) + return 0; + + CfFileOp *fop = cf_fileop_from_json (fop_json, error); + if (!fop) + return -1; + + cf_fileop_aborted (fop); + cf_fileop_json_free (fop); + + return 0; +#else + return 0; +#endif +} + GList * seafile_list_dir_with_perm (const char *repo_id, const char *path, diff --git a/fileserver/cf_fileop.go b/fileserver/cf_fileop.go new file mode 100644 index 00000000..348ce8de --- /dev/null +++ b/fileserver/cf_fileop.go @@ -0,0 +1,276 @@ +// CloudFile write lifecycle seam in the Go fileserver. +// +// The Go fileserver chunks the body, writes blocks and fs objects, generates a +// commit and updates the branch entirely on its own -- it never enters +// repo-op.c, where the C seam lives. It is therefore the one write path the C +// side cannot see, and a capability that only guarded C would leave every +// upload and every sync unguarded. +// +// Rather than reimplement the adjudication in Go, this asks seaf-server over +// RPC, so C stays the single authority. Same shape and the same reasoning as +// cf_ext.go: a second implementation is a second thing to drift. +// +// Where this deliberately differs from cf_ext.go +// +// cf_ext.go caches its answer for five minutes and treats an RPC failure as +// permissive. Both are right for a slow-moving library-level question. Neither +// is right here: +// +// - Caching "no provider is registered" would leave a window, after an +// operator enables a capability and restarts seaf-server, in which every +// upload bypasses it. A security hole that closes by itself after a few +// minutes is the worst kind to diagnose. So the verdict is never cached; +// prepare is asked every time. +// +// - Failing open on an RPC error would do the same thing whenever +// seaf-server hiccups. So once a provider is known to be registered, an +// unreachable RPC fails closed, per the third iron law: when the rules +// cannot be read, refuse. +// +// What IS cached is only which of three worlds we are in, and only to pick the +// failure mode: +// +// unsupported -- no such RPC, i.e. an upstream seaf-server. Skip entirely; +// costs nothing. Re-probed occasionally so that losing the +// race with seaf-server's startup heals itself. +// inactive -- the RPC exists, no capability registered. Ask anyway (C +// answers off one global bool, no query), but fail open if +// the RPC is unreachable: there is nothing to enforce, and +// breaking uploads on a baseline deployment would be a +// regression against stock CE. +// active -- a capability is registered. Ask, and fail closed. +// +// Contract: cloudfile-docker/docs/fileop-lifecycle.md + +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// Operation vocabulary. Must stay identical to common/cf-fileop.h; the shared +// case set in cloudfile-docker/docs/fileop-cases.json checks both ends. +const ( + cfOpCreateFile = "create-file" + cfOpUpdateFile = "update-file" + cfOpDelete = "delete" + cfOpMkdir = "mkdir" + cfOpRename = "rename" + cfOpMove = "move" + cfOpCopy = "copy" + cfOpRevertFile = "revert-file" + cfOpRevertDir = "revert-dir" + cfOpRevertRepo = "revert-repo" + cfOpUpdateDir = "update-dir" + cfOpUploadBlocks = "upload-blocks" + cfOpSyncUpdate = "sync-update" +) + +// cfFileOp is the wire form of C's CfFileOp. Field names must match +// common/cf-fileop-json.c. +type cfFileOp struct { + Op string `json:"op"` + + RepoID string `json:"repo_id,omitempty"` + Dir string `json:"dir,omitempty"` + Name string `json:"name,omitempty"` + Names []string `json:"names,omitempty"` + + SrcRepoID string `json:"src_repo_id,omitempty"` + SrcDir string `json:"src_dir,omitempty"` + SrcName string `json:"src_name,omitempty"` + SrcNames []string `json:"src_names,omitempty"` + + User string `json:"user,omitempty"` + Client string `json:"client,omitempty"` + ExpectCommitID string `json:"expect_commit_id,omitempty"` + + CommitID string `json:"commit_id,omitempty"` + FileID string `json:"file_id,omitempty"` +} + +type cfVerdict struct { + Allowed bool `json:"allowed"` + Code int `json:"code"` + Message string `json:"message"` +} + +// CloudFile's own error codes, mirroring common/cf-fileop.h. +const ( + cfErrFileLocked = 600 + cfErrVersionMismatch = 601 +) + +const ( + cfSeamUnknown = iota + cfSeamUnsupported + cfSeamInactive + cfSeamActive +) + +// How long an "unsupported" verdict stands before we probe again. Long enough +// that an upstream build pays almost nothing, short enough that starting +// before seaf-server does not disable the seam for the process lifetime. +const cfSeamReprobeSeconds = 300 + +var ( + cfSeamMu sync.Mutex + cfSeamState = cfSeamUnknown + cfSeamProbeAt int64 + cfSeamWarned bool +) + +// cfSeam reports which of the three worlds we are in, probing seaf-server when +// the cached answer has expired. +func cfSeam() int { + cfSeamMu.Lock() + defer cfSeamMu.Unlock() + + now := time.Now().Unix() + if cfSeamState != cfSeamUnknown && now < cfSeamProbeAt { + return cfSeamState + } + + ret, err := rpcclient.Call("cf_fileop_active") + if err != nil { + // No such RPC: an upstream seaf-server, or it is not up yet. + cfSeamState = cfSeamUnsupported + cfSeamProbeAt = now + cfSeamReprobeSeconds + if !cfSeamWarned { + cfSeamWarned = true + log.Printf("CloudFile: write lifecycle RPC unavailable (%v); "+ + "treating this server as stock CE and re-probing every %ds", + err, cfSeamReprobeSeconds) + } + return cfSeamState + } + + cfSeamWarned = false + + active := false + switch v := ret.(type) { + case float64: + active = v != 0 + case int64: + active = v != 0 + case json.Number: + n, convErr := v.Int64() + active = convErr == nil && n != 0 + } + + if active { + cfSeamState = cfSeamActive + } else { + cfSeamState = cfSeamInactive + } + + // Short-lived on purpose: this only selects the failure mode, and the + // verdict below is asked fresh every time regardless. + cfSeamProbeAt = now + 30 + + return cfSeamState +} + +// cfHTTPStatus maps a provider's refusal code to what the client should see. +func cfHTTPStatus(code int) int { + switch code { + case cfErrFileLocked: + return http.StatusLocked + case cfErrVersionMismatch: + return http.StatusConflict + default: + return http.StatusForbidden + } +} + +// cfFileOpPrepare asks whether a write may proceed. Returns nil to allow. +// +// The refusal message is passed through to the client verbatim: it is the only +// thing that can explain who holds the lock and for how long, and a refusal +// nobody can act on becomes a support ticket with nothing in it. +func cfFileOpPrepare(fop *cfFileOp) *appError { + state := cfSeam() + if state == cfSeamUnsupported { + return nil + } + + payload, err := json.Marshal(fop) + if err != nil { + // Our own struct failed to marshal: a bug, not a server problem. + // Refusing is still correct -- we cannot ask, so we cannot allow. + err := fmt.Errorf("failed to encode file operation: %v", err) + return &appError{err, "", http.StatusInternalServerError} + } + + ret, err := rpcclient.Call("cf_fileop_prepare", string(payload)) + if err != nil { + if state == cfSeamInactive { + // Nothing is registered, so there is nothing this call could have + // refused. Letting the write through keeps a baseline deployment + // behaving exactly like stock CE when seaf-server blips. + log.Printf("CloudFile: prepare RPC failed with no provider registered, allowing: %v", err) + return nil + } + log.Printf("CloudFile: prepare RPC failed with a provider registered, refusing: %v", err) + return &appError{err, "The server cannot verify this operation right now.", + http.StatusServiceUnavailable} + } + + raw, ok := ret.(string) + if !ok || raw == "" { + if state == cfSeamInactive { + return nil + } + err := fmt.Errorf("malformed verdict from cf_fileop_prepare: %v", ret) + return &appError{err, "", http.StatusServiceUnavailable} + } + + var verdict cfVerdict + if err := json.Unmarshal([]byte(raw), &verdict); err != nil { + if state == cfSeamInactive { + return nil + } + err := fmt.Errorf("failed to decode verdict %q: %v", raw, err) + return &appError{err, "", http.StatusServiceUnavailable} + } + + if verdict.Allowed { + return nil + } + + return &appError{nil, verdict.Message, cfHTTPStatus(verdict.Code)} +} + +// cfFileOpReport sends COMMITTED or ABORTED. Neither can change the outcome, +// so a failure here is logged and swallowed: the write already happened (or +// already failed), and turning a bookkeeping error into a client error would +// make the client retry something that took effect. +func cfFileOpReport(rpcName string, fop *cfFileOp) { + if cfSeam() != cfSeamActive { + return + } + + payload, err := json.Marshal(fop) + if err != nil { + log.Printf("CloudFile: failed to encode %s payload: %v", rpcName, err) + return + } + + if _, err := rpcclient.Call(rpcName, string(payload)); err != nil { + log.Printf("CloudFile: %s RPC failed: %v", rpcName, err) + } +} + +func cfFileOpCommitted(fop *cfFileOp) { + cfFileOpReport("cf_fileop_committed", fop) +} + +func cfFileOpAborted(fop *cfFileOp) { + cfFileOpReport("cf_fileop_aborted", fop) +} diff --git a/fileserver/cf_fileop_test.go b/fileserver/cf_fileop_test.go new file mode 100644 index 00000000..23992237 --- /dev/null +++ b/fileserver/cf_fileop_test.go @@ -0,0 +1,267 @@ +// Cross-language contract checks for the write lifecycle seam. +// +// The Go side does not classify operations -- it asks seaf-server. So what +// there is to test here is not logic but agreement: that Go's vocabulary is +// exactly C's, and that the JSON field names Go emits are exactly the ones +// cf-fileop-json.c reads. Those are the two ways this seam can break silently. +// +// A typo in a Go operation constant would sail past the compiler, past go vet +// and past a smoke test: seaf-server would refuse the unknown operation, the +// upload would fail with a generic error, and nothing would point at the +// constant. A renamed JSON key is worse -- the field simply arrives empty, so +// a lock keyed on a path would adjudicate the wrong object, or none. +// +// Run with: go test ./... -run CfFileOp + +package main + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" + "testing" +) + +// goOperations is every operation constant this package defines. Kept as a +// literal rather than derived: deriving it from the same source the test +// checks would make the test agree with itself. +var goOperations = []string{ + cfOpCreateFile, + cfOpUpdateFile, + cfOpDelete, + cfOpMkdir, + cfOpRename, + cfOpMove, + cfOpCopy, + cfOpRevertFile, + cfOpRevertDir, + cfOpRevertRepo, + cfOpUpdateDir, + cfOpUploadBlocks, + cfOpSyncUpdate, +} + +func repoRoot(t *testing.T) string { + t.Helper() + root, err := filepath.Abs("..") + if err != nil { + t.Fatalf("failed to resolve repo root: %v", err) + } + return root +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + return string(data) +} + +// TestCfFileOpVocabularyMatchesC checks Go's constants against the #defines in +// common/cf-fileop.h, in both directions. +func TestCfFileOpVocabularyMatchesC(t *testing.T) { + header := readFile(t, filepath.Join(repoRoot(t), "common", "cf-fileop.h")) + + re := regexp.MustCompile(`#define (CF_OP_\w+)\s+"([^"]+)"`) + cOps := make(map[string]string) + for _, m := range re.FindAllStringSubmatch(header, -1) { + cOps[m[2]] = m[1] + } + + if len(cOps) == 0 { + t.Fatal("found no CF_OP_* defines in cf-fileop.h") + } + + for _, op := range goOperations { + if _, ok := cOps[op]; !ok { + t.Errorf("Go defines operation %q, C does not", op) + } + } + + goSet := make(map[string]bool, len(goOperations)) + for _, op := range goOperations { + goSet[op] = true + } + for value, name := range cOps { + if !goSet[value] { + t.Errorf("C defines %s = %q, Go does not", name, value) + } + } +} + +// TestCfFileOpJSONKeysMatchC checks that every key the Go struct emits is a +// key cf-fileop-json.c actually reads, and vice versa. A key that only one +// side knows about is silently dropped -- no error, no log, just an empty +// field where a path should have been. +func TestCfFileOpJSONKeysMatchC(t *testing.T) { + source := readFile(t, filepath.Join(repoRoot(t), "common", "cf-fileop-json.c")) + + re := regexp.MustCompile(`dup_string_(?:member|array) \(obj, "(\w+)"\)`) + cKeys := make(map[string]bool) + for _, m := range re.FindAllStringSubmatch(source, -1) { + cKeys[m[1]] = true + } + if len(cKeys) == 0 { + t.Fatal("found no JSON keys in cf-fileop-json.c") + } + + goKeys := make(map[string]bool) + typ := reflect.TypeOf(cfFileOp{}) + for i := 0; i < typ.NumField(); i++ { + tag := typ.Field(i).Tag.Get("json") + if tag == "" || tag == "-" { + t.Errorf("field %s has no json tag", typ.Field(i).Name) + continue + } + goKeys[strings.Split(tag, ",")[0]] = true + } + + for key := range goKeys { + if !cKeys[key] { + t.Errorf("Go emits JSON key %q, cf-fileop-json.c does not read it", key) + } + } + for key := range cKeys { + if !goKeys[key] { + t.Errorf("cf-fileop-json.c reads JSON key %q, Go never emits it", key) + } + } +} + +// TestCfFileOpErrorCodesMatchC guards the two CloudFile error codes, which are +// the only thing turning a refusal into a 423 rather than a 403. +func TestCfFileOpErrorCodesMatchC(t *testing.T) { + header := readFile(t, filepath.Join(repoRoot(t), "common", "cf-fileop.h")) + + for name, want := range map[string]int{ + "CF_ERR_FILE_LOCKED": cfErrFileLocked, + "CF_ERR_VERSION_MISMATCH": cfErrVersionMismatch, + } { + re := regexp.MustCompile(`#define ` + name + `\s+(\d+)`) + m := re.FindStringSubmatch(header) + if m == nil { + t.Errorf("%s is not defined in cf-fileop.h", name) + continue + } + if m[1] != itoa(want) { + t.Errorf("%s is %s in C but %d in Go", name, m[1], want) + } + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var digits []byte + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + return string(digits) +} + +func TestCfFileOpHTTPStatus(t *testing.T) { + cases := []struct { + code int + want int + }{ + {cfErrFileLocked, http.StatusLocked}, + {cfErrVersionMismatch, http.StatusConflict}, + {500, http.StatusForbidden}, + {0, http.StatusForbidden}, + } + for _, c := range cases { + if got := cfHTTPStatus(c.code); got != c.want { + t.Errorf("cfHTTPStatus(%d) = %d, want %d", c.code, got, c.want) + } + } +} + +// TestCfFileOpVerdictDecoding covers what comes back over the wire, including +// the shapes that must NOT be read as permission to write. +func TestCfFileOpVerdictDecoding(t *testing.T) { + cases := []struct { + name string + raw string + wantAllowed bool + wantCode int + }{ + {"allow", `{"allowed":true}`, true, 0}, + {"refuse locked", `{"allowed":false,"code":600,"message":"Locked by alice"}`, false, 600}, + {"refuse generic", `{"allowed":false,"code":500,"message":"no"}`, false, 500}, + // An empty object decodes with Allowed false. That is the safe + // default and the test exists to keep it that way: a verdict Go + // cannot understand must never mean yes. + {"empty object", `{}`, false, 0}, + } + + for _, c := range cases { + var v cfVerdict + if err := json.Unmarshal([]byte(c.raw), &v); err != nil { + t.Errorf("%s: unexpected decode error: %v", c.name, err) + continue + } + if v.Allowed != c.wantAllowed { + t.Errorf("%s: allowed = %v, want %v", c.name, v.Allowed, c.wantAllowed) + } + if v.Code != c.wantCode { + t.Errorf("%s: code = %d, want %d", c.name, v.Code, c.wantCode) + } + } +} + +// TestCfFileOpSharedCaseSet drives the Go vocabulary from the same file the C +// suite uses, so the two cannot be updated apart. Skipped when the +// cloudfile-docker repo is not checked out beside this one. +func TestCfFileOpSharedCaseSet(t *testing.T) { + path := os.Getenv("CF_FILEOP_CASES") + if path == "" { + root := repoRoot(t) + path = filepath.Join(filepath.Dir(root), "cloudfile-docker", + "docs", "fileop-cases.json") + } + + data, err := os.ReadFile(path) + if err != nil { + t.Skipf("shared case set not found at %s; set CF_FILEOP_CASES", path) + } + + var cases struct { + Operations struct { + Cases []struct { + Op string `json:"op"` + Valid bool `json:"valid"` + } `json:"cases"` + } `json:"operations"` + } + if err := json.Unmarshal(data, &cases); err != nil { + t.Fatalf("failed to parse %s: %v", path, err) + } + + var wantValid []string + for _, c := range cases.Operations.Cases { + if c.Valid { + wantValid = append(wantValid, c.Op) + } + } + if len(wantValid) == 0 { + t.Fatal("shared case set lists no valid operations") + } + + got := append([]string(nil), goOperations...) + sort.Strings(got) + sort.Strings(wantValid) + + if !reflect.DeepEqual(got, wantValid) { + t.Errorf("Go vocabulary does not match the shared case set:\n got: %v\nwant: %v", + got, wantValid) + } +} diff --git a/fileserver/fileop.go b/fileserver/fileop.go index 5044eed8..a64e602a 100644 --- a/fileserver/fileop.go +++ b/fileserver/fileop.go @@ -1501,6 +1501,23 @@ func mkdirWithParents(repoID, parentDir, newDirPath, user string) error { parentDirCan = getCanonPath(parentDir) } + // CloudFile: one PREPARE for the deepest path asked for, matching + // seaf_repo_manager_mkdir_with_parents -- the whole thing lands in a + // single commit, so it is a single operation. + cfFop := &cfFileOp{ + Op: cfOpMkdir, + RepoID: repoID, + Dir: parentDirCan, + Name: relativeDirCan, + User: user, + } + if appErr := cfFileOpPrepare(cfFop); appErr != nil { + if appErr.Error != nil { + return appErr.Error + } + return fmt.Errorf("%s", appErr.Message) + } + gcID, err := repomgr.GetCurrentGCID(repo.StoreID) if err != nil { err := fmt.Errorf("failed to get current gc id: %v", err) @@ -1509,10 +1526,13 @@ func mkdirWithParents(repoID, parentDir, newDirPath, user string) error { absPath, dirID, err := checkAndCreateDir(repo, headCommit.RootID, parentDirCan, subFolders) if err != nil { + cfFileOpAborted(cfFop) err := fmt.Errorf("failed to check and create dir: %v", err) return err } if absPath == "" { + // Every level already existed: no commit, so no fact -- only the abort. + cfFileOpAborted(cfFop) return nil } newRootID := headCommit.RootID @@ -1523,17 +1543,22 @@ func mkdirWithParents(repoID, parentDir, newDirPath, user string) error { var names []string rootID, _ = doPostMultiFiles(repo, newRootID, filepath.Dir(absPath), []*fsmgr.SeafDirent{dent}, user, false, &names) if rootID == "" { + cfFileOpAborted(cfFop) err := fmt.Errorf("failed to put dir") return err } buf := fmt.Sprintf("Added directory \"%s\"", relativeDirCan) - _, err = genNewCommit(repo, headCommit, rootID, user, buf, true, gcID, true) + newCommitID, err := genNewCommit(repo, headCommit, rootID, user, buf, true, gcID, true) if err != nil { + cfFileOpAborted(cfFop) err := fmt.Errorf("failed to generate new commit: %v", err) return err } + cfFop.CommitID = newCommitID + cfFileOpCommitted(cfFop) + go mergeVirtualRepoPool.AddTask(repo.ID, "") return nil @@ -1810,6 +1835,19 @@ func postMultiFiles(rsp http.ResponseWriter, r *http.Request, repoID, parentDir, cryptKey = key } + // CloudFile: one PREPARE for the batch, before a single block is written. + // Asking after indexing would mean a refused upload had already spent the + // disk and the CPU. + if appErr := cfFileOpPrepare(&cfFileOp{ + Op: cfOpCreateFile, + RepoID: repoID, + Dir: canonPath, + Names: fileNames, + User: user, + }); appErr != nil { + return appErr + } + gcID, err := repomgr.GetCurrentGCID(repo.StoreID) if err != nil { err := fmt.Errorf("failed to get current gc id for repo %s: %v", repoID, err) @@ -1848,6 +1886,13 @@ func postMultiFiles(rsp http.ResponseWriter, r *http.Request, repoID, parentDir, retStr, err := postFilesAndGenCommit(fileNames, repo.ID, user, canonPath, replace, ids, sizes, lastModify, gcID) if err != nil { + cfFileOpAborted(&cfFileOp{ + Op: cfOpCreateFile, + RepoID: repoID, + Dir: canonPath, + Names: fileNames, + User: user, + }) if errors.Is(err, ErrGCConflict) { return &appError{nil, "GC Conflict.\n", http.StatusConflict} } else { @@ -1923,6 +1968,7 @@ func postFilesAndGenCommit(fileNames []string, repoID string, user, canonPath st } var names []string var retryCnt int + var newCommitID string var dents []*fsmgr.SeafDirent for i, name := range fileNames { @@ -1952,7 +1998,7 @@ retry: buf = fmt.Sprintf("Added \"%s\".", fileNames[0]) } - _, err = genNewCommit(repo, headCommit, rootID, user, buf, handleConncurrentUpdate, lastGCID, true) + newCommitID, err = genNewCommit(repo, headCommit, rootID, user, buf, handleConncurrentUpdate, lastGCID, true) if err != nil { if err != ErrConflict { err := fmt.Errorf("failed to generate new commit: %w", err) @@ -1976,6 +2022,17 @@ retry: goto retry } + // CloudFile: past the retry loop, so one fact however many attempts it + // took. `names` is what actually landed after deduplication. + cfFileOpCommitted(&cfFileOp{ + Op: cfOpCreateFile, + RepoID: repoID, + Dir: canonPath, + Names: names, + User: user, + CommitID: newCommitID, + }) + go mergeVirtualRepoPool.AddTask(repo.ID, "") retJSON, err := formatJSONRet(names, ids, sizes) @@ -3342,6 +3399,21 @@ func putFile(rsp http.ResponseWriter, r *http.Request, repoID, parentDir, user, return &appError{nil, msg, seafHTTPResNotExists} } + // CloudFile: before any block is written. headID is the caller's expected + // source version when it supplied one; P1 turns it into the optimistic + // concurrency check, here it is only carried. + cfFop := &cfFileOp{ + Op: cfOpUpdateFile, + RepoID: repoID, + Dir: canonPath, + Name: fileName, + User: user, + ExpectCommitID: headID, + } + if appErr := cfFileOpPrepare(cfFop); appErr != nil { + return appErr + } + var cryptKey *seafileCrypt if repo.IsEncrypted { key, err := parseCryptKey(rsp, repoID, user, repo.EncVersion) @@ -3388,6 +3460,8 @@ func putFile(rsp http.ResponseWriter, r *http.Request, repoID, parentDir, user, fullPath := filepath.Join(parentDir, fileName) oldFileID, _, _ := fsmgr.GetObjIDByPath(repo.StoreID, headCommit.RootID, fullPath) if fileID == oldFileID { + // Identical content: no commit, so no fact -- only the abort. + cfFileOpAborted(cfFop) if isAjax { retJSON, err := formatUpdateJSONRet(fileName, fileID, size) if err != nil { @@ -3411,13 +3485,15 @@ func putFile(rsp http.ResponseWriter, r *http.Request, repoID, parentDir, user, var names []string rootID, err := doPostMultiFiles(repo, headCommit.RootID, canonPath, []*fsmgr.SeafDirent{newDent}, user, true, &names) if err != nil { + cfFileOpAborted(cfFop) err := fmt.Errorf("failed to put file %s to %s in repo %s: %v", fileName, canonPath, repo.ID, err) return &appError{err, "", http.StatusInternalServerError} } desc := fmt.Sprintf("Modified \"%s\"", fileName) - _, err = genNewCommit(repo, headCommit, rootID, user, desc, true, gcID, true) + newCommitID, err := genNewCommit(repo, headCommit, rootID, user, desc, true, gcID, true) if err != nil { + cfFileOpAborted(cfFop) if errors.Is(err, ErrGCConflict) { return &appError{nil, "GC Conflict.\n", http.StatusConflict} } else { @@ -3426,6 +3502,10 @@ func putFile(rsp http.ResponseWriter, r *http.Request, repoID, parentDir, user, } } + cfFop.CommitID = newCommitID + cfFop.FileID = fileID + cfFileOpCommitted(cfFop) + if isAjax { retJSON, err := formatUpdateJSONRet(fileName, fileID, size) if err != nil { @@ -3634,6 +3714,25 @@ func commitFileBlocks(repoID, parentDir, fileName, blockIDsJSON, user string, fi return "", appErr } + // CloudFile: create vs update reflects the caller's intent (`replace`), + // not verified prior existence -- telling them apart would cost a + // directory lookup on every chunked upload and no consumer needs it. + // Matches seaf_repo_manager_commit_file_blocks on the C side. + cfOp := cfOpCreateFile + if replace { + cfOp = cfOpUpdateFile + } + cfFop := &cfFileOp{ + Op: cfOp, + RepoID: repoID, + Dir: canonPath, + Name: fileName, + User: user, + } + if appErr := cfFileOpPrepare(cfFop); appErr != nil { + return "", appErr + } + gcID, err := repomgr.GetCurrentGCID(repo.StoreID) if err != nil { err := fmt.Errorf("failed to get current gc id: %v", err) @@ -3654,13 +3753,15 @@ func commitFileBlocks(repoID, parentDir, fileName, blockIDsJSON, user string, fi var names []string rootID, err := doPostMultiFiles(repo, headCommit.RootID, canonPath, []*fsmgr.SeafDirent{newDent}, user, replace, &names) if err != nil { + cfFileOpAborted(cfFop) err := fmt.Errorf("failed to post file %s to %s in repo %s: %v", fileName, canonPath, repo.ID, err) return "", &appError{err, "", http.StatusInternalServerError} } desc := fmt.Sprintf("Added \"%s\"", fileName) - _, err = genNewCommit(repo, headCommit, rootID, user, desc, true, gcID, true) + newCommitID, err := genNewCommit(repo, headCommit, rootID, user, desc, true, gcID, true) if err != nil { + cfFileOpAborted(cfFop) if errors.Is(err, ErrGCConflict) { return "", &appError{nil, "GC Conflict.\n", http.StatusConflict} } else { @@ -3669,6 +3770,10 @@ func commitFileBlocks(repoID, parentDir, fileName, blockIDsJSON, user string, fi } } + cfFop.CommitID = newCommitID + cfFop.FileID = fileID + cfFileOpCommitted(cfFop) + return fileID, nil } @@ -3797,11 +3902,23 @@ func postBlocks(repoID, user string, fsm *recvData) *appError { return &appError{err, msg, http.StatusInternalServerError} } + // CloudFile: pathless. These blocks enter the object store without + // touching any directory tree, so there is no lock subject; a lock + // provider ignores this op and adjudicates the create-file or update-file + // that commits the blocks into a path. + cfFop := &cfFileOp{Op: cfOpUploadBlocks, RepoID: repoID, User: user} + if appErr := cfFileOpPrepare(cfFop); appErr != nil { + return appErr + } + if err := indexRawBlocks(repo.StoreID, blockIDs, fileHeaders); err != nil { + cfFileOpAborted(cfFop) err := fmt.Errorf("failed to index file blocks") return &appError{err, "", http.StatusInternalServerError} } + cfFileOpCommitted(cfFop) + go updateSizePool.AddTask(repo.ID) return nil diff --git a/fileserver/sync_api.go b/fileserver/sync_api.go index 3f728c14..33f1ebff 100644 --- a/fileserver/sync_api.go +++ b/fileserver/sync_api.go @@ -1072,11 +1072,30 @@ func putUpdateBranchCB(rsp http.ResponseWriter, r *http.Request) *appError { } } + // CloudFile: the sync protocol trades commits, fs objects and blocks -- + // it carries no paths, so there is no per-file adjudication point once a + // sync is under way. The library-level question is the only one that can + // be asked, exactly as with the ACL subtree check above. This is the + // contract's one structurally coarse operation, not a first-version + // limitation; see fileop-lifecycle.md section 3. + cfFop := &cfFileOp{ + Op: cfOpSyncUpdate, + RepoID: repoID, + Dir: "/", + User: user, + ExpectCommitID: newCommit.ParentID.String, + CommitID: newCommitID, + } + if appErr := cfFileOpPrepare(cfFop); appErr != nil { + return appErr + } + token := r.Header.Get("Seafile-Repo-Token") if token == "" { token = utils.GetAuthorizationToken(r.Header) } if err := fastForwardOrMerge(user, token, repo, base, newCommit); err != nil { + cfFileOpAborted(cfFop) if errors.Is(err, ErrGCConflict) { return &appError{nil, "GC Conflict.\n", http.StatusConflict} } else { @@ -1085,6 +1104,8 @@ func putUpdateBranchCB(rsp http.ResponseWriter, r *http.Request) *appError { } } + cfFileOpCommitted(cfFop) + go mergeVirtualRepoPool.AddTask(repoID, "") go updateSizePool.AddTask(repoID) diff --git a/include/seafile-rpc.h b/include/seafile-rpc.h index 05d365f3..679ec749 100644 --- a/include/seafile-rpc.h +++ b/include/seafile-rpc.h @@ -968,6 +968,32 @@ char * seafile_cf_find_restricted_path (const char *repo_id, const char *path, const char *user, GError **error); +/* + * CloudFile: write lifecycle, for the Go fileserver. + * + * @fop_json is the JSON form of a CfFileOp (see common/cf-fileop-json.h). + * committed and aborted always return 0 because the write has already + * happened either way; prepare answers with a verdict, see below. + * + * All four are no-ops returning 0 when no capability has registered. + */ +int +seafile_cf_fileop_active (GError **error); + +/* + * Returns a JSON verdict: {"allowed":true} or + * {"allowed":false,"code":,"message":"..."}. A refusal is a normal answer, + * so it is not raised as a GError -- NULL means the payload was malformed. + */ +char * +seafile_cf_fileop_prepare (const char *fop_json, GError **error); + +int +seafile_cf_fileop_committed (const char *fop_json, GError **error); + +int +seafile_cf_fileop_aborted (const char *fop_json, GError **error); + GList * seafile_list_dir_with_perm (const char *repo_id, const char *path, diff --git a/python/seafile/rpcclient.py b/python/seafile/rpcclient.py index 81411efd..61fa0565 100644 --- a/python/seafile/rpcclient.py +++ b/python/seafile/rpcclient.py @@ -511,6 +511,29 @@ def check_permission_by_path(repo_id, path, user): def cf_find_restricted_path(repo_id, path, user): pass + # CloudFile write lifecycle. `fop_json` is the JSON form of a CfFileOp; + # see cloudfile-docker/docs/fileop-lifecycle.md section 4. + # + # seafdav and Seahub do NOT call these: their writes go through + # seafile_api.post_file and friends, which land in repo-op.c where the + # seam already runs. Exposed here for the Go fileserver and for tests + # that need to assert the baseline is a pass-through. + @searpc_func("int", []) + def cf_fileop_active(): + pass + + @searpc_func("string", ["string"]) + def cf_fileop_prepare(fop_json): + pass + + @searpc_func("int", ["string"]) + def cf_fileop_committed(fop_json): + pass + + @searpc_func("int", ["string"]) + def cf_fileop_aborted(fop_json): + pass + # org repo @searpc_func("string", ["string", "string", "string", "string", "string", "int", "int"]) def seafile_create_org_repo(name, desc, user, passwd, magic, random_key, enc_version, org_id): diff --git a/python/seaserv/api.py b/python/seaserv/api.py index 94edee06..35a9d4f3 100644 --- a/python/seaserv/api.py +++ b/python/seaserv/api.py @@ -698,6 +698,18 @@ def _cf_find_restricted_path(self, repo_id, path, user): return None return restricted or None + def cf_fileop_active(self): + """CloudFile: whether any write lifecycle provider is registered. + + Returns False against an upstream seaf-server, which has no such RPC. + Exists so the baseline gate can assert the seam is installed but + inert, rather than inferring it from the absence of symptoms. + """ + try: + return bool(seafserv_threaded_rpc.cf_fileop_active()) + except Exception: + return False + def is_repo_syncable(self, repo_id, user, repo_perm, client=None): """ Check if the permission of the repo is syncable. diff --git a/server/Makefile.am b/server/Makefile.am index 57e3d5d9..63628eb3 100644 --- a/server/Makefile.am +++ b/server/Makefile.am @@ -38,6 +38,9 @@ noinst_HEADERS = web-accesstoken-mgr.h seafile-session.h \ ../common/group-mgr.h \ ../common/org-mgr.h \ ../common/cf-ext.h \ + ../common/cf-fileop.h \ + ../common/cf-fileop-json.h \ + ../common/cf-path.h \ ../common/cf-acl.h \ ../common/cf-acl-resolve.h \ ../common/cf-s3-client.h \ @@ -91,6 +94,9 @@ seaf_server_SOURCES = \ ../common/group-mgr.c \ ../common/org-mgr.c \ ../common/cf-ext.c \ + ../common/cf-fileop.c \ + ../common/cf-fileop-json.c \ + ../common/cf-path.c \ ../common/cf-acl.c \ ../common/cf-acl-resolve.c \ ../common/block-mgr.c \ diff --git a/server/repo-op.c b/server/repo-op.c index 21930284..78affab3 100644 --- a/server/repo-op.c +++ b/server/repo-op.c @@ -26,6 +26,22 @@ #include "seaf-db.h" +/* CloudFile write lifecycle seam. Every entry point below announces PREPARE + * before it persists anything and COMMITTED after the commit lands, so a + * capability -- the file lock first -- can refuse a write or observe one + * without patching each caller. + * + * The seam is here rather than in rpc-service.c on purpose: upload-file.c, + * the virtual repo merge and copy-mgr all reach these functions directly, + * and an adjudication point with a way around it is not one. + * + * Every call site is behind cf_fileop_active(), so a build with no capability + * registered does one global read and changes nothing. + * + * Contract: cloudfile-docker/docs/fileop-lifecycle.md + */ +#include "cf-fileop.h" + #define INDEX_DIR "index" #define PREFIX_DEL_FILE "Deleted \"" @@ -37,6 +53,49 @@ gboolean should_ignore_file(const char *filename, void *data); +/* + * CloudFile: the delete entry points take a JSON array of names rather than a + * single name, so the seam has to decode it to report every affected path. + * Callers guard on cf_fileop_active(), so nothing is parsed on a build with no + * capability registered. + * + * Returns 0 to allow, -1 to refuse (PREPARE only). + */ +static GList * +json_to_file_list (const char *files_json); + +static int +cf_fileop_json_names (CfFileOpPhase phase, + const char *op, + const char *repo_id, + const char *dir, + const char *names_json, + const char *user, + const char *commit_id, + GError **error) +{ + GList *names = json_to_file_list (names_json); + CfFileOp fop = { .op = op, .repo_id = repo_id, .dir = dir, + .names = names, .user = user, .commit_id = commit_id }; + int rc = 0; + + switch (phase) { + case CF_FILEOP_PHASE_PREPARE: + rc = cf_fileop_prepare (&fop, error); + break; + case CF_FILEOP_PHASE_COMMITTED: + cf_fileop_committed (&fop); + break; + case CF_FILEOP_PHASE_ABORTED: + cf_fileop_aborted (&fop); + break; + } + + string_list_free (names); + + return rc; +} + static gboolean is_virtual_repo_and_origin (SeafRepo *repo1, SeafRepo *repo2); @@ -654,6 +713,8 @@ seaf_repo_manager_post_file (SeafRepoManager *mgr, char *gc_id = NULL; int ret = 0; int retry_cnt = 0; + gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; if (g_access (temp_file_path, R_OK) != 0) { seaf_warning ("[post file] File %s doesn't exist or not readable.\n", @@ -684,7 +745,15 @@ seaf_repo_manager_post_file (SeafRepoManager *mgr, ret = -1; goto out; } - + + if (CF_FILEOP_PREPARE (CF_OP_CREATE_FILE, error, + .repo_id = repo_id, .dir = canon_path, + .name = file_name, .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + /* Write blocks. */ if (repo->encrypted) { unsigned char key[32], iv[16]; @@ -733,7 +802,7 @@ seaf_repo_manager_post_file (SeafRepoManager *mgr, snprintf(buf, SEAF_PATH_MAX, "Added \"%s\"", file_name); if (gen_new_commit (repo_id, head_commit, root_id, - user, buf, NULL, FALSE, TRUE, gc_id, error) < 0) { + user, buf, cf_commit_id, FALSE, TRUE, gc_id, error) < 0) { if (*error == NULL || (*error)->code != SEAF_ERR_CONCURRENT_UPLOAD) { ret = -1; goto out; @@ -756,9 +825,22 @@ seaf_repo_manager_post_file (SeafRepoManager *mgr, goto retry; } + /* Past the retry loop, so this fires once no matter how many attempts the + * commit took. */ + CF_FILEOP_COMMITTED (CF_OP_CREATE_FILE, + .repo_id = repo_id, .dir = canon_path, + .name = file_name, .user = user, + .commit_id = cf_commit_id, .file_id = hex); + cf_prepared = FALSE; + seaf_repo_manager_merge_virtual_repo (mgr, repo_id, NULL); out: + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_CREATE_FILE, + .repo_id = repo_id, .dir = canon_path, + .name = file_name, .user = user); + if (repo) seaf_repo_unref (repo); if (head_commit) @@ -1106,6 +1188,7 @@ seaf_repo_manager_post_multi_files (SeafRepoManager *mgr, SeafileCrypt *crypt = NULL; char hex[41]; int ret = 0; + gboolean cf_prepared = FALSE; GET_REPO_OR_FAIL(repo, repo_id); @@ -1148,6 +1231,19 @@ seaf_repo_manager_post_multi_files (SeafRepoManager *mgr, goto out; } + /* One PREPARE for the whole batch, before any block is indexed. It has to + * be here rather than in post_files_and_gen_commit because the task_id + * branch below hands the commit to the async indexer -- adjudicating there + * would mean the blocks are already written when the answer is no. + */ + if (CF_FILEOP_PREPARE (CF_OP_CREATE_FILE, error, + .repo_id = repo_id, .dir = canon_path, + .names = filenames, .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + /* Index tmp files and get file id list. */ if (repo->encrypted) { unsigned char key[32], iv[16]; @@ -1187,6 +1283,10 @@ seaf_repo_manager_post_multi_files (SeafRepoManager *mgr, id_list = g_list_reverse (id_list); size_list = g_list_reverse (size_list); + /* From here the commit -- and therefore the fact and the abort -- + * belongs to post_files_and_gen_commit. Clearing the flag first is + * what stops one failure from producing two ABORTEDs. */ + cf_prepared = FALSE; ret = post_files_and_gen_commit (filenames, repo->id, user, @@ -1199,6 +1299,8 @@ seaf_repo_manager_post_multi_files (SeafRepoManager *mgr, gc_id, error); } else { + /* Same hand-off: the async indexer calls post_files_and_gen_commit. */ + cf_prepared = FALSE; ret = index_blocks_mgr_start_index (seaf->index_blocks_mgr, filenames, paths, @@ -1212,6 +1314,13 @@ seaf_repo_manager_post_multi_files (SeafRepoManager *mgr, } out: + /* Only reached with the flag still set when indexing failed before any + * commit was attempted. */ + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_CREATE_FILE, + .repo_id = repo_id, .dir = canon_path, + .names = filenames, .user = user); + if (repo) seaf_repo_unref (repo); string_list_free (filenames); @@ -1248,6 +1357,7 @@ post_files_and_gen_commit (GList *filenames, int ret = 0; int retry_cnt = 0; gboolean handle_concurrent_update = TRUE; + char cf_commit_id[41] = ""; if (replace_existed == 0) { handle_concurrent_update = FALSE; @@ -1277,7 +1387,7 @@ post_files_and_gen_commit (GList *filenames, g_string_printf (buf, "Added \"%s\".", (char *)(filenames->data)); if (gen_new_commit (repo->id, head_commit, root_id, - user, buf->str, NULL, handle_concurrent_update, TRUE, last_gc_id, error) < 0) { + user, buf->str, cf_commit_id, handle_concurrent_update, TRUE, last_gc_id, error) < 0) { if (*error == NULL || (*error)->code != SEAF_ERR_CONCURRENT_UPLOAD) { ret = -1; goto out; @@ -1300,6 +1410,16 @@ post_files_and_gen_commit (GList *filenames, goto retry; } + /* One fact for the batch, carrying every name -- not one per file. This is + * also the async indexer's commit point, so both upload paths report here. + * @name_list is what actually landed after deduplication, which is what a + * consumer needs; @filenames is what was asked for. + */ + CF_FILEOP_COMMITTED (CF_OP_CREATE_FILE, + .repo_id = repo_id, .dir = canon_path, + .names = name_list, .user = user, + .commit_id = cf_commit_id); + seaf_repo_manager_merge_virtual_repo (seaf->repo_mgr, repo->id, NULL); if (ret_json) @@ -1308,6 +1428,11 @@ post_files_and_gen_commit (GList *filenames, update_repo_size(repo->id); out: + if (ret != 0) + CF_FILEOP_ABORTED (CF_OP_CREATE_FILE, + .repo_id = repo_id, .dir = canon_path, + .names = filenames, .user = user); + if (repo) seaf_repo_unref (repo); if (head_commit) @@ -1448,6 +1573,7 @@ seaf_repo_manager_post_blocks (SeafRepoManager *mgr, SeafRepo *repo = NULL; GList *blockids = NULL, *paths = NULL, *ptr; int ret = 0; + gboolean cf_prepared = FALSE; blockids = json_to_file_list (blockids_json); paths = json_to_file_list (paths_json); @@ -1472,6 +1598,18 @@ seaf_repo_manager_post_blocks (SeafRepoManager *mgr, GET_REPO_OR_FAIL(repo, repo_id); + /* Pathless: these blocks enter the object store without touching any + * directory tree, so there is no lock subject here. A lock provider must + * ignore this op -- the adjudication happens in the create-file or + * update-file that commits these blocks into a path. + */ + if (CF_FILEOP_PREPARE (CF_OP_UPLOAD_BLOCKS, error, + .repo_id = repo_id, .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + /* Write blocks. */ if (seaf_fs_manager_index_raw_blocks (seaf->fs_mgr, repo->store_id, @@ -1485,7 +1623,15 @@ seaf_repo_manager_post_blocks (SeafRepoManager *mgr, goto out; } + CF_FILEOP_COMMITTED (CF_OP_UPLOAD_BLOCKS, + .repo_id = repo_id, .user = user); + cf_prepared = FALSE; + out: + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_UPLOAD_BLOCKS, + .repo_id = repo_id, .user = user); + if (repo) seaf_repo_unref (repo); string_list_free (blockids); @@ -1547,6 +1693,13 @@ seaf_repo_manager_commit_file_blocks (SeafRepoManager *mgr, char hex[41]; char *gc_id = NULL; int ret = 0; + gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; + /* Intent, not verified prior existence: telling the two apart would cost a + * directory lookup on every chunked upload, and no consumer needs it -- + * a lock refuses both alike. Noted in fileop-lifecycle.md section 3. + */ + const char *cf_op = replace_existed ? CF_OP_UPDATE_FILE : CF_OP_CREATE_FILE; blockids = json_to_file_list (blockids_json); @@ -1580,6 +1733,14 @@ seaf_repo_manager_commit_file_blocks (SeafRepoManager *mgr, goto out; } + if (CF_FILEOP_PREPARE (cf_op, error, + .repo_id = repo_id, .dir = canon_path, + .name = file_name, .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + gc_id = seaf_repo_get_current_gc_id (repo); /* Write blocks. */ @@ -1615,10 +1776,22 @@ seaf_repo_manager_commit_file_blocks (SeafRepoManager *mgr, *new_id = g_strdup(hex); snprintf(buf, SEAF_PATH_MAX, "Added \"%s\"", file_name); if (gen_new_commit (repo_id, head_commit, root_id, - user, buf, NULL, TRUE, TRUE, gc_id, error) < 0) + user, buf, cf_commit_id, TRUE, TRUE, gc_id, error) < 0) ret = -1; + else { + CF_FILEOP_COMMITTED (cf_op, + .repo_id = repo_id, .dir = canon_path, + .name = file_name, .user = user, + .commit_id = cf_commit_id, .file_id = hex); + cf_prepared = FALSE; + } out: + if (cf_prepared) + CF_FILEOP_ABORTED (cf_op, + .repo_id = repo_id, .dir = canon_path, + .name = file_name, .user = user); + if (repo) seaf_repo_unref (repo); if (head_commit) @@ -1798,6 +1971,8 @@ seaf_repo_manager_del_file (SeafRepoManager *mgr, int ret = 0; int deleted_num = 0; char *gc_id = NULL; + gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; GET_REPO_OR_FAIL(repo, repo_id); GET_COMMIT_OR_FAIL(head_commit, repo->id, repo->version, repo->head->commit_id); @@ -1815,6 +1990,17 @@ seaf_repo_manager_del_file (SeafRepoManager *mgr, goto out; } + /* @file_name is a JSON array here, not one name -- see del_file_recursive. */ + if (cf_fileop_active ()) { + if (cf_fileop_json_names (CF_FILEOP_PHASE_PREPARE, CF_OP_DELETE, + repo_id, canon_path, file_name, user, + NULL, error) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + } + gc_id = seaf_repo_get_current_gc_id (repo); root_id = do_del_file (repo, @@ -1843,14 +2029,28 @@ seaf_repo_manager_del_file (SeafRepoManager *mgr, } if (gen_new_commit (repo_id, head_commit, root_id, - user, buf, NULL, TRUE, TRUE, gc_id, error) < 0) { + user, buf, cf_commit_id, TRUE, TRUE, gc_id, error) < 0) { ret = -1; goto out; } + if (cf_fileop_active ()) { + cf_fileop_json_names (CF_FILEOP_PHASE_COMMITTED, CF_OP_DELETE, + repo_id, canon_path, file_name, user, + cf_commit_id, NULL); + cf_prepared = FALSE; + } + seaf_repo_manager_merge_virtual_repo (mgr, repo_id, NULL); out: + /* deleted_num == 0 leaves the tree untouched and jumps here with ret == 0: + * nothing was written, so there is no fact to report, only the abort. */ + if (cf_prepared) + cf_fileop_json_names (CF_FILEOP_PHASE_ABORTED, CF_OP_DELETE, + repo_id, canon_path, file_name, user, + NULL, NULL); + if (repo) seaf_repo_unref (repo); if (head_commit) @@ -1922,6 +2122,8 @@ seaf_repo_manager_batch_del_files (SeafRepoManager *mgr, int ret = 0; int deleted_num = 0; char *gc_id = NULL; + gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; GET_REPO_OR_FAIL(repo, repo_id); GET_COMMIT_OR_FAIL(head_commit, repo->id, repo->version, repo->head->commit_id); @@ -1936,6 +2138,18 @@ seaf_repo_manager_batch_del_files (SeafRepoManager *mgr, goto out; } + /* @file_list holds full paths, so the dir is the repo root and each name + * is already absolute; cf_path_join collapses the doubled separator. */ + if (cf_fileop_active ()) { + if (cf_fileop_json_names (CF_FILEOP_PHASE_PREPARE, CF_OP_DELETE, + repo_id, "/", file_list, user, + NULL, error) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + } + changeset = changeset_new (repo_id, dir); if (!changeset) { g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_GENERAL, @@ -1972,14 +2186,25 @@ seaf_repo_manager_batch_del_files (SeafRepoManager *mgr, } if (gen_new_commit (repo_id, head_commit, root_id, - user, buf, NULL, TRUE, TRUE, gc_id, error) < 0) { + user, buf, cf_commit_id, TRUE, TRUE, gc_id, error) < 0) { ret = -1; goto out; } + if (cf_fileop_active ()) { + cf_fileop_json_names (CF_FILEOP_PHASE_COMMITTED, CF_OP_DELETE, + repo_id, "/", file_list, user, + cf_commit_id, NULL); + cf_prepared = FALSE; + } + seaf_repo_manager_merge_virtual_repo (mgr, repo_id, NULL); out: + if (cf_prepared) + cf_fileop_json_names (CF_FILEOP_PHASE_ABORTED, CF_OP_DELETE, + repo_id, "/", file_list, user, NULL, NULL); + changeset_free (changeset); if (repo) seaf_repo_unref (repo); @@ -2815,6 +3040,7 @@ seaf_repo_manager_copy_file (SeafRepoManager *mgr, gboolean background = FALSE; char *task_id = NULL; SeafileCopyResult *res= NULL; + gboolean cf_prepared = FALSE; GET_REPO_OR_FAIL(src_repo, src_repo_id); @@ -2827,22 +3053,39 @@ seaf_repo_manager_copy_file (SeafRepoManager *mgr, ret = -1; goto out; } - + } else { seaf_repo_ref (src_repo); dst_repo = src_repo; } - + src_canon_path = get_canonical_path (src_path); dst_canon_path = get_canonical_path (dst_path); GET_COMMIT_OR_FAIL(dst_head_commit, - dst_repo->id, dst_repo->version, + dst_repo->id, dst_repo->version, dst_repo->head->commit_id); - + /* FAIL_IF_FILE_EXISTS(dst_repo->store_id, dst_repo->version, dst_head_commit->root_id, dst_canon_path, dst_filename, NULL); */ + /* Only the destination is a write; the source is read and is the ACL's + * business, not this seam's. The source is still reported because a lock + * provider needs it to tell a same-repo copy from a cross-repo one. + * + * Covers the async branch too: the copy task is scheduled below, so + * refusing here is the last point at which nothing has been written. + */ + if (CF_FILEOP_PREPARE (CF_OP_COPY, error, + .repo_id = dst_repo_id, .dir = dst_canon_path, + .name = dst_filename, + .src_repo_id = src_repo_id, .src_dir = src_canon_path, + .src_name = src_filename, .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + if (strcmp (src_repo_id, dst_repo_id) == 0 || is_virtual_repo_and_origin (src_repo, dst_repo)) { @@ -2919,7 +3162,24 @@ seaf_repo_manager_copy_file (SeafRepoManager *mgr, } } + /* The background branch reports the fact once the copy is scheduled: the + * task runs cross_repo_copy, which commits through this same file, so a + * second COMMITTED would double-count. */ + CF_FILEOP_COMMITTED (CF_OP_COPY, + .repo_id = dst_repo_id, .dir = dst_canon_path, + .name = dst_filename, + .src_repo_id = src_repo_id, .src_dir = src_canon_path, + .src_name = src_filename, .user = user); + cf_prepared = FALSE; + out: + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_COPY, + .repo_id = dst_repo_id, .dir = dst_canon_path, + .name = dst_filename, + .src_repo_id = src_repo_id, .src_dir = src_canon_path, + .src_name = src_filename, .user = user); + if (src_repo) seaf_repo_unref (src_repo); if (dst_repo) @@ -2975,6 +3235,7 @@ seaf_repo_manager_copy_multiple_files (SeafRepoManager *mgr, GList *src_names = NULL, *dst_names = NULL, *ptr; SeafileCopyResult *res = NULL; GHashTable *dirent_hash = NULL; + gboolean cf_prepared = FALSE; GET_REPO_OR_FAIL(src_repo, src_repo_id); @@ -3009,6 +3270,16 @@ seaf_repo_manager_copy_multiple_files (SeafRepoManager *mgr, goto out; } + if (CF_FILEOP_PREPARE (CF_OP_COPY, error, + .repo_id = dst_repo_id, .dir = dst_canon_path, + .names = dst_names, + .src_repo_id = src_repo_id, .src_dir = src_canon_path, + .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + /* copy file within the same repo */ if (src_repo == dst_repo || is_virtual_repo_and_origin (src_repo, dst_repo)) { @@ -3124,7 +3395,21 @@ seaf_repo_manager_copy_multiple_files (SeafRepoManager *mgr, } // Synchronous copy } //else diffrent repo + CF_FILEOP_COMMITTED (CF_OP_COPY, + .repo_id = dst_repo_id, .dir = dst_canon_path, + .names = dst_names, + .src_repo_id = src_repo_id, .src_dir = src_canon_path, + .user = user); + cf_prepared = FALSE; + out: + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_COPY, + .repo_id = dst_repo_id, .dir = dst_canon_path, + .names = dst_names, + .src_repo_id = src_repo_id, .src_dir = src_canon_path, + .user = user); + if (src_repo) seaf_repo_unref (src_repo); if (dst_repo) seaf_repo_unref (dst_repo); @@ -3513,6 +3798,7 @@ seaf_repo_manager_move_multiple_files (SeafRepoManager *mgr, GList *src_names = NULL, *dst_names = NULL, *ptr; SeafileCopyResult *res = NULL; GHashTable *dirent_hash = NULL; + gboolean cf_prepared = FALSE; GET_REPO_OR_FAIL(src_repo, src_repo_id); @@ -3548,6 +3834,19 @@ seaf_repo_manager_move_multiple_files (SeafRepoManager *mgr, goto out; } + /* A move writes both ends -- the entry leaves the source directory -- so + * both name lists go to the provider. A lock on a source file has to + * refuse this, not just a lock on the destination. */ + if (CF_FILEOP_PREPARE (CF_OP_MOVE, error, + .repo_id = dst_repo_id, .dir = dst_canon_path, + .names = dst_names, + .src_repo_id = src_repo_id, .src_dir = src_canon_path, + .src_names = src_names, .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + gboolean is_virtual_origin = is_virtual_repo_and_origin (src_repo, dst_repo); if (src_repo == dst_repo || is_virtual_origin) { /* get src dirents */ @@ -3678,12 +3977,26 @@ seaf_repo_manager_move_multiple_files (SeafRepoManager *mgr, } // Synchronous move } //else diffrent repo + CF_FILEOP_COMMITTED (CF_OP_MOVE, + .repo_id = dst_repo_id, .dir = dst_canon_path, + .names = dst_names, + .src_repo_id = src_repo_id, .src_dir = src_canon_path, + .src_names = src_names, .user = user); + cf_prepared = FALSE; + out: + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_MOVE, + .repo_id = dst_repo_id, .dir = dst_canon_path, + .names = dst_names, + .src_repo_id = src_repo_id, .src_dir = src_canon_path, + .src_names = src_names, .user = user); + if (src_repo) seaf_repo_unref (src_repo); if (dst_repo) seaf_repo_unref (dst_repo); if (dst_head_commit) seaf_commit_unref(dst_head_commit); - + if (src_canon_path) g_free (src_canon_path); if (dst_canon_path) g_free (dst_canon_path); @@ -3736,8 +4049,10 @@ seaf_repo_manager_mkdir_with_parents (SeafRepoManager *mgr, GList *uncre_dir_list = NULL; GList *iter_list = NULL; char *uncre_dir; - int ret = 0; + int ret = 0; char *gc_id = NULL; + gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; if (new_dir_path[0] == '/' || new_dir_path[0] == '\\') { seaf_warning ("[mkdir with parent] Invalid relative path %s.\n", new_dir_path); @@ -3782,6 +4097,16 @@ seaf_repo_manager_mkdir_with_parents (SeafRepoManager *mgr, ret = -1; goto out; } + /* One PREPARE for the deepest path asked for, not one per level created: + * the whole thing lands in a single commit, so it is a single operation. */ + if (CF_FILEOP_PREPARE (CF_OP_MKDIR, error, + .repo_id = repo_id, .dir = parent_dir_can, + .name = relative_dir_can, .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + total_path_len = strlen (abs_path); // from the last, to check the folder exist @@ -3848,17 +4173,30 @@ seaf_repo_manager_mkdir_with_parents (SeafRepoManager *mgr, /* Commit. */ snprintf(buf, SEAF_PATH_MAX, "Added directory \"%s\"", relative_dir_can); if (gen_new_commit (repo_id, head_commit, root_id, - user, buf, NULL, TRUE, TRUE, gc_id, error) < 0) { + user, buf, cf_commit_id, TRUE, TRUE, gc_id, error) < 0) { ret = -1; g_free (root_id); goto out; } + CF_FILEOP_COMMITTED (CF_OP_MKDIR, + .repo_id = repo_id, .dir = parent_dir_can, + .name = relative_dir_can, .user = user, + .commit_id = cf_commit_id); + cf_prepared = FALSE; + seaf_repo_manager_merge_virtual_repo (mgr, repo_id, NULL); g_free (root_id); } out: + /* An empty uncre_dir_list means every level already existed: no commit, so + * no fact -- only the abort, since PREPARE did run. */ + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_MKDIR, + .repo_id = repo_id, .dir = parent_dir_can, + .name = relative_dir_can, .user = user); + if (repo) seaf_repo_unref (repo); if (head_commit) @@ -3894,6 +4232,8 @@ seaf_repo_manager_post_dir (SeafRepoManager *mgr, SeafDirent *new_dent = NULL; int ret = 0; char *gc_id = NULL; + gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; GET_REPO_OR_FAIL(repo, repo_id); GET_COMMIT_OR_FAIL(head_commit, repo->id, repo->version, repo->head->commit_id); @@ -3911,6 +4251,14 @@ seaf_repo_manager_post_dir (SeafRepoManager *mgr, FAIL_IF_FILE_EXISTS(repo->store_id, repo->version, head_commit->root_id, canon_path, new_dir_name, NULL); + if (CF_FILEOP_PREPARE (CF_OP_MKDIR, error, + .repo_id = repo_id, .dir = canon_path, + .name = new_dir_name, .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + if (!new_dent) { new_dent = seaf_dirent_new (dir_version_from_repo_version(repo->version), EMPTY_SHA1, S_IFDIR, new_dir_name, @@ -3933,14 +4281,25 @@ seaf_repo_manager_post_dir (SeafRepoManager *mgr, /* Commit. */ snprintf(buf, SEAF_PATH_MAX, "Added directory \"%s\"", new_dir_name); if (gen_new_commit (repo_id, head_commit, root_id, - user, buf, NULL, TRUE, TRUE, gc_id, error) < 0) { + user, buf, cf_commit_id, TRUE, TRUE, gc_id, error) < 0) { ret = -1; goto out; } + CF_FILEOP_COMMITTED (CF_OP_MKDIR, + .repo_id = repo_id, .dir = canon_path, + .name = new_dir_name, .user = user, + .commit_id = cf_commit_id); + cf_prepared = FALSE; + seaf_repo_manager_merge_virtual_repo (mgr, repo_id, NULL); out: + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_MKDIR, + .repo_id = repo_id, .dir = canon_path, + .name = new_dir_name, .user = user); + if (repo) seaf_repo_unref (repo); if (head_commit) @@ -3969,6 +4328,8 @@ seaf_repo_manager_post_empty_file (SeafRepoManager *mgr, SeafDirent *new_dent = NULL; int ret = 0; char *gc_id = NULL; + gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; GET_REPO_OR_FAIL(repo, repo_id); GET_COMMIT_OR_FAIL(head_commit, repo->id, repo->version, repo->head->commit_id); @@ -3988,6 +4349,14 @@ seaf_repo_manager_post_empty_file (SeafRepoManager *mgr, FAIL_IF_FILE_EXISTS(repo->store_id, repo->version, head_commit->root_id, canon_path, new_file_name, NULL); + if (CF_FILEOP_PREPARE (CF_OP_CREATE_FILE, error, + .repo_id = repo_id, .dir = canon_path, + .name = new_file_name, .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + if (!new_dent) { new_dent = seaf_dirent_new (dir_version_from_repo_version(repo->version), EMPTY_SHA1, STD_FILE_MODE, new_file_name, @@ -4009,16 +4378,27 @@ seaf_repo_manager_post_empty_file (SeafRepoManager *mgr, /* Commit. */ snprintf(buf, SEAF_PATH_MAX, "Added \"%s\"", new_file_name); if (gen_new_commit (repo_id, head_commit, root_id, - user, buf, NULL, TRUE, TRUE, gc_id, error) < 0) { + user, buf, cf_commit_id, TRUE, TRUE, gc_id, error) < 0) { ret = -1; goto out; } + CF_FILEOP_COMMITTED (CF_OP_CREATE_FILE, + .repo_id = repo_id, .dir = canon_path, + .name = new_file_name, .user = user, + .commit_id = cf_commit_id); + cf_prepared = FALSE; + seaf_repo_manager_merge_virtual_repo (mgr, repo_id, NULL); update_repo_size (repo_id); out: + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_CREATE_FILE, + .repo_id = repo_id, .dir = canon_path, + .name = new_file_name, .user = user); + if (repo) seaf_repo_unref (repo); if (head_commit) @@ -4164,6 +4544,8 @@ seaf_repo_manager_rename_file (SeafRepoManager *mgr, int mode = 0; int ret = 0; char *gc_id = NULL; + gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; if (strcmp(oldname, newname) == 0) return 0; @@ -4187,6 +4569,19 @@ seaf_repo_manager_rename_file (SeafRepoManager *mgr, FAIL_IF_FILE_EXISTS(repo->store_id, repo->version, head_commit->root_id, canon_path, newname, NULL); + /* Not modelled as a move: Pro keeps a lock across a same-repo rename but + * refuses a cross-repo move, so collapsing the two would erase exactly the + * distinction the lock needs. */ + if (CF_FILEOP_PREPARE (CF_OP_RENAME, error, + .repo_id = repo_id, .dir = canon_path, + .name = newname, + .src_repo_id = repo_id, .src_dir = canon_path, + .src_name = oldname, .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + gc_id = seaf_repo_get_current_gc_id (repo); root_id = do_rename_file (repo, head_commit->root_id, canon_path, @@ -4206,14 +4601,29 @@ seaf_repo_manager_rename_file (SeafRepoManager *mgr, } if (gen_new_commit (repo_id, head_commit, root_id, - user, buf, NULL, TRUE, TRUE, gc_id, error) < 0) { + user, buf, cf_commit_id, TRUE, TRUE, gc_id, error) < 0) { ret = -1; goto out; } + CF_FILEOP_COMMITTED (CF_OP_RENAME, + .repo_id = repo_id, .dir = canon_path, + .name = newname, + .src_repo_id = repo_id, .src_dir = canon_path, + .src_name = oldname, .user = user, + .commit_id = cf_commit_id); + cf_prepared = FALSE; + seaf_repo_manager_merge_virtual_repo (mgr, repo_id, NULL); out: + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_RENAME, + .repo_id = repo_id, .dir = canon_path, + .name = newname, + .src_repo_id = repo_id, .src_dir = canon_path, + .src_name = oldname, .user = user); + if (repo) seaf_repo_unref (repo); if (head_commit) @@ -4352,6 +4762,8 @@ seaf_repo_manager_put_file (SeafRepoManager *mgr, char *old_file_id = NULL, *fullpath = NULL; char *gc_id = NULL; int ret = 0; + gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; if (g_access (temp_file_path, R_OK) != 0) { seaf_warning ("[put file] File %s doesn't exist or not readable.\n", @@ -4387,6 +4799,19 @@ seaf_repo_manager_put_file (SeafRepoManager *mgr, FAIL_IF_FILE_NOT_EXISTS(repo->store_id, repo->version, head_commit->root_id, canon_path, file_name, NULL); + /* The one entry point that already carries the caller's expected source + * version: @head_id is optional upstream, so it is passed through as-is + * and stays NULL when the caller did not supply one. P1 turns it into the + * optimistic-concurrency check; here it is only carried. */ + if (CF_FILEOP_PREPARE (CF_OP_UPDATE_FILE, error, + .repo_id = repo_id, .dir = canon_path, + .name = file_name, .user = user, + .expect_commit_id = head_id) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + /* Write blocks. */ if (repo->encrypted) { unsigned char key[32], iv[16]; @@ -4450,17 +4875,31 @@ seaf_repo_manager_put_file (SeafRepoManager *mgr, /* Commit. */ snprintf(buf, SEAF_PATH_MAX, "Modified \"%s\"", file_name); - if (gen_new_commit (repo_id, head_commit, root_id, user, buf, NULL, TRUE, TRUE, gc_id, error) < 0) { + if (gen_new_commit (repo_id, head_commit, root_id, user, buf, cf_commit_id, TRUE, TRUE, gc_id, error) < 0) { ret = -1; - goto out; + goto out; } + CF_FILEOP_COMMITTED (CF_OP_UPDATE_FILE, + .repo_id = repo_id, .dir = canon_path, + .name = file_name, .user = user, + .expect_commit_id = head_id, + .commit_id = cf_commit_id, .file_id = hex); + cf_prepared = FALSE; + if (new_file_id) *new_file_id = g_strdup(new_dent->id); seaf_repo_manager_merge_virtual_repo (mgr, repo_id, NULL); out: + /* Also covers the identical-content short circuit above, which returns + * success without a commit: nothing was written, so no fact. */ + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_UPDATE_FILE, + .repo_id = repo_id, .dir = canon_path, + .name = file_name, .user = user); + if (repo) seaf_repo_unref (repo); if (head_commit) @@ -4522,11 +4961,27 @@ seaf_repo_manager_update_dir (SeafRepoManager *mgr, char *commit_desc = NULL; int ret = 0; char *gc_id = NULL; + gboolean cf_prepared = FALSE; + char cf_commit_id_buf[41] = ""; + /* Report whatever id the commit actually got, whether or not the caller + * asked for it back. */ + char *cf_commit_id = new_commit_id ? new_commit_id : cf_commit_id_buf; GET_REPO_OR_FAIL(repo, repo_id); const char *base = head_id ? head_id : repo->head->commit_id; GET_COMMIT_OR_FAIL(head_commit, repo->id, repo->version, base); + /* Before the root/subdir split, so both branches are covered by one + * PREPARE. This is the entry point the sync client and WebDAV reach when + * they replace a whole directory object. */ + if (CF_FILEOP_PREPARE (CF_OP_UPDATE_DIR, error, + .repo_id = repo_id, .dir = dir_path, + .user = user, .expect_commit_id = head_id) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + /* Are we updating the root? */ if (strcmp (dir_path, "/") == 0) { commit_desc = gen_commit_description (repo, new_dir_id, head_commit->root_id); @@ -4534,8 +4989,15 @@ seaf_repo_manager_update_dir (SeafRepoManager *mgr, commit_desc = g_strdup("Auto merge by system"); if (gen_new_commit (repo_id, head_commit, new_dir_id, - user, commit_desc, new_commit_id, TRUE, FALSE, NULL, error) < 0) + user, commit_desc, cf_commit_id, TRUE, FALSE, NULL, error) < 0) ret = -1; + else { + CF_FILEOP_COMMITTED (CF_OP_UPDATE_DIR, + .repo_id = repo_id, .dir = dir_path, + .user = user, .expect_commit_id = head_id, + .commit_id = cf_commit_id); + cf_prepared = FALSE; + } g_free (commit_desc); goto out; } @@ -4569,14 +5031,25 @@ seaf_repo_manager_update_dir (SeafRepoManager *mgr, commit_desc = g_strdup("Auto merge by system"); if (gen_new_commit (repo_id, head_commit, root_id, - user, commit_desc, new_commit_id, TRUE, TRUE, gc_id, error) < 0) { + user, commit_desc, cf_commit_id, TRUE, TRUE, gc_id, error) < 0) { ret = -1; g_free (commit_desc); goto out; } g_free (commit_desc); + CF_FILEOP_COMMITTED (CF_OP_UPDATE_DIR, + .repo_id = repo_id, .dir = dir_path, + .user = user, .expect_commit_id = head_id, + .commit_id = cf_commit_id); + cf_prepared = FALSE; + out: + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_UPDATE_DIR, + .repo_id = repo_id, .dir = dir_path, + .user = user, .expect_commit_id = head_id); + seaf_repo_unref (repo); seaf_commit_unref (head_commit); seaf_dirent_free (new_dent); @@ -4953,6 +5426,8 @@ seaf_repo_manager_revert_file (SeafRepoManager *mgr, gboolean skipped = FALSE; int ret = 0; char *gc_id = NULL; + gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; GET_REPO_OR_FAIL(repo, repo_id); GET_COMMIT_OR_FAIL(head_commit, repo->id, repo->version, repo->head->commit_id); @@ -5013,6 +5488,21 @@ seaf_repo_manager_revert_file (SeafRepoManager *mgr, goto out; } + /* Restoring an old version overwrites the current one, so a lock on the + * path has to refuse it -- section 4.8 of the lock spec lists "restore an + * old version" among the operations a lock blocks. + * + * The missing-parent branch below calls mkdir_with_parents, which runs its + * own PREPARE for the directories. That nesting is intended: two commits + * really do happen. */ + if (CF_FILEOP_PREPARE (CF_OP_REVERT_FILE, error, + .repo_id = repo_id, .dir = canon_path, + .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + gc_id = seaf_repo_get_current_gc_id (repo); if (!parent_dir_exist) { @@ -5078,14 +5568,25 @@ seaf_repo_manager_revert_file (SeafRepoManager *mgr, #endif snprintf(buf, SEAF_PATH_MAX, "Reverted file \"%s\" to status at %s", filename, time_str); if (gen_new_commit (repo_id, head_commit, root_id, - user, buf, NULL, TRUE, TRUE, gc_id, error) < 0) { + user, buf, cf_commit_id, TRUE, TRUE, gc_id, error) < 0) { ret = -1; goto out; } + CF_FILEOP_COMMITTED (CF_OP_REVERT_FILE, + .repo_id = repo_id, .dir = canon_path, + .user = user, .commit_id = cf_commit_id); + cf_prepared = FALSE; + seaf_repo_manager_merge_virtual_repo (mgr, repo_id, NULL); out: + /* Also covers the `skipped` path, which succeeds without a commit. */ + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_REVERT_FILE, + .repo_id = repo_id, .dir = canon_path, + .user = user); + if (repo) seaf_repo_unref (repo); if (head_commit) @@ -5189,6 +5690,8 @@ seaf_repo_manager_revert_dir (SeafRepoManager *mgr, gboolean skipped = FALSE; int ret = 0; char *gc_id = NULL; + gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; GET_REPO_OR_FAIL(repo, repo_id); GET_COMMIT_OR_FAIL(head_commit, repo->id, repo->version, repo->head->commit_id); @@ -5243,6 +5746,14 @@ seaf_repo_manager_revert_dir (SeafRepoManager *mgr, goto out; } + if (CF_FILEOP_PREPARE (CF_OP_REVERT_DIR, error, + .repo_id = repo_id, .dir = canon_path, + .user = user) < 0) { + ret = -1; + goto out; + } + cf_prepared = TRUE; + gc_id = seaf_repo_get_current_gc_id (repo); if (!parent_dir_exist) { @@ -5304,14 +5815,25 @@ seaf_repo_manager_revert_dir (SeafRepoManager *mgr, /* Commit. */ snprintf(buf, SEAF_PATH_MAX, "Recovered deleted directory \"%s\"", dirname); if (gen_new_commit (repo_id, head_commit, root_id, - user, buf, NULL, TRUE, TRUE, gc_id, error) < 0) { + user, buf, cf_commit_id, TRUE, TRUE, gc_id, error) < 0) { ret = -1; goto out; } + CF_FILEOP_COMMITTED (CF_OP_REVERT_DIR, + .repo_id = repo_id, .dir = canon_path, + .user = user, .commit_id = cf_commit_id); + cf_prepared = FALSE; + seaf_repo_manager_merge_virtual_repo (mgr, repo_id, NULL); out: + /* Also covers the `skipped` path, which succeeds without a commit. */ + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_REVERT_DIR, + .repo_id = repo_id, .dir = canon_path, + .user = user); + if (repo) seaf_repo_unref (repo); if (head_commit) @@ -6229,6 +6751,16 @@ seaf_repo_manager_revert_on_server (SeafRepoManager *mgr, SeafCommit *commit = NULL, *new_commit = NULL; char desc[512]; int ret = 0; + gboolean cf_prepared = FALSE; + + /* Outside the retry label: rewinding the whole library is one operation no + * matter how many times the branch update loses a race. */ + if (CF_FILEOP_PREPARE (CF_OP_REVERT_REPO, error, + .repo_id = repo_id, .dir = "/", + .user = user_name, + .expect_commit_id = commit_id) < 0) + return -1; + cf_prepared = TRUE; retry: repo = seaf_repo_manager_get_repo (mgr, repo_id); @@ -6282,9 +6814,22 @@ seaf_repo_manager_revert_on_server (SeafRepoManager *mgr, goto retry; } + CF_FILEOP_COMMITTED (CF_OP_REVERT_REPO, + .repo_id = repo_id, .dir = "/", + .user = user_name, + .expect_commit_id = commit_id, + .commit_id = new_commit->commit_id); + cf_prepared = FALSE; + seaf_repo_manager_merge_virtual_repo (mgr, repo_id, NULL); out: + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_REVERT_REPO, + .repo_id = repo_id, .dir = "/", + .user = user_name, + .expect_commit_id = commit_id); + if (new_commit) seaf_commit_unref (new_commit); if (commit) diff --git a/server/seaf-server.c b/server/seaf-server.c index 49050ba4..4529ab9b 100644 --- a/server/seaf-server.c +++ b/server/seaf-server.c @@ -675,6 +675,27 @@ static void start_rpc_service (const char *seafile_dir, seafile_cf_find_restricted_path, "cf_find_restricted_path", searpc_signature_string__string_string_string()); + + /* CloudFile write lifecycle */ + searpc_server_register_function ("seafserv-threaded-rpcserver", + seafile_cf_fileop_active, + "cf_fileop_active", + searpc_signature_int__void()); + + searpc_server_register_function ("seafserv-threaded-rpcserver", + seafile_cf_fileop_prepare, + "cf_fileop_prepare", + searpc_signature_string__string()); + + searpc_server_register_function ("seafserv-threaded-rpcserver", + seafile_cf_fileop_committed, + "cf_fileop_committed", + searpc_signature_int__string()); + + searpc_server_register_function ("seafserv-threaded-rpcserver", + seafile_cf_fileop_aborted, + "cf_fileop_aborted", + searpc_signature_int__string()); searpc_server_register_function ("seafserv-threaded-rpcserver", seafile_get_file_id_by_commit_and_path, diff --git a/tests/cf-acl/run.sh b/tests/cf-acl/run.sh index cf29a7a2..9f2413a5 100755 --- a/tests/cf-acl/run.sh +++ b/tests/cf-acl/run.sh @@ -26,9 +26,14 @@ trap 'rm -rf "$build"' EXIT python3 "$here/gen-cases.py" "$cases" > "$build/cf-acl-cases.h" +# cf-path.c holds the path normalization that used to live in +# cf-acl-resolve.c. It moved down to the baseline when the write lifecycle +# seam needed the same rules -- see common/cf-path.h for why one +# implementation rather than two. cc -std=c99 -Wall -Wextra -Wno-unused-parameter -o "$build/test-cf-acl" \ "$here/test-cf-acl.c" \ "$repo_root/common/cf-acl-resolve.c" \ + "$repo_root/common/cf-path.c" \ -I"$repo_root/common" -I"$build" \ $(pkg-config --cflags --libs glib-2.0) diff --git a/tests/cf-fileop/check-call-sites.py b/tests/cf-fileop/check-call-sites.py new file mode 100755 index 00000000..c6c60231 --- /dev/null +++ b/tests/cf-fileop/check-call-sites.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Compile-check the seam's call sites without the full seafile build. + +server/repo-op.c holds every C write entry point, and it cannot be compiled on +a developer machine -- it needs searpc, jansson, the vala-generated object +headers and the rest of the seafile tree. So the mistakes that designated +initializers make easy (a misspelled field, a stale operation name, a missing +comma that silently turns two arguments into one) would first surface on a +Linux CI run twenty minutes later. + +This extracts every CF_FILEOP_* invocation, replaces each value expression +with a dummy of the declared field type, and compiles the result against the +real cf-fileop.h. What survives is exactly the part that does not depend on +seafile: the operation vocabulary, the field names, the arity and the macro +expansion itself. + +What it deliberately does NOT check: whether the value passed is the right +variable. `.name = parent_dir` type-checks and is wrong. Only a review or an +end-to-end run catches that. + +Usage: check-call-sites.py +""" + +import os +import re +import subprocess +import sys +import tempfile + +MACROS = ('CF_FILEOP_PREPARE', 'CF_FILEOP_COMMITTED', 'CF_FILEOP_ABORTED') + +SOURCES = ('server/repo-op.c',) + + +def parse_struct_fields(header): + """Field name -> C type, from the CfFileOp definition.""" + body = re.search(r'typedef struct CfFileOp \{(.*?)\} CfFileOp;', + header, re.S) + if not body: + sys.exit('could not find the CfFileOp definition in cf-fileop.h') + + fields = {} + for line in body.group(1).split('\n'): + line = re.sub(r'/\*.*?\*/', '', line).strip() + m = re.match(r'^(const char|GList|CfFileOpPhase)\s*(\**)\s*(\w+)\s*;', line) + if m: + base, stars, name = m.groups() + fields[name] = (base + ' ' + stars).strip() + return fields + + +def parse_operations(header): + return set(re.findall(r'#define (CF_OP_\w+)\s', header)) + + +def extract_calls(path): + """Yield (macro, argument text, line number) for each invocation.""" + with open(path, encoding='utf-8') as fp: + src = fp.read() + + for macro in MACROS: + for m in re.finditer(r'\b%s\s*\(' % macro, src): + start = m.end() + depth = 1 + i = start + while i < len(src) and depth: + if src[i] == '(': + depth += 1 + elif src[i] == ')': + depth -= 1 + i += 1 + if depth: + sys.exit('%s: unbalanced parentheses after %s' % (path, macro)) + yield macro, src[start:i - 1], src.count('\n', 0, m.start()) + 1 + + +def split_args(text): + """Split on top-level commas only.""" + args, depth, current = [], 0, [] + for ch in text: + if ch in '([': + depth += 1 + elif ch in ')]': + depth -= 1 + if ch == ',' and depth == 0: + args.append(''.join(current).strip()) + current = [] + else: + current.append(ch) + if current: + args.append(''.join(current).strip()) + return args + + +DUMMY = {'const char *': 'CF_DUMMY_STR', 'GList *': 'CF_DUMMY_LIST'} + + +def main(): + if len(sys.argv) != 2: + sys.stderr.write(__doc__) + return 2 + + root = sys.argv[1] + header = open(os.path.join(root, 'common', 'cf-fileop.h'), + encoding='utf-8').read() + fields = parse_struct_fields(header) + operations = parse_operations(header) + + errors = [] + body = [] + total = 0 + + for source in SOURCES: + path = os.path.join(root, source) + for macro, argtext, line in extract_calls(path): + total += 1 + where = '%s:%d' % (source, line) + args = split_args(argtext) + + if not args: + errors.append('%s: %s with no arguments' % (where, macro)) + continue + + op = args[0] + op_expr = op + if op not in operations: + # Either a ternary picking between two operations, or a local + # holding one -- commit_file_blocks uses a local because it + # reports caller intent and needs the same value three times. + # Resolve the local to its initializer before checking. + mentioned = re.findall(r'CF_OP_\w+', op) + if not mentioned and re.fullmatch(r'\w+', op): + decl = re.search( + r'const char \*%s\s*=\s*([^;]+);' % re.escape(op), + open(path, encoding='utf-8').read()) + if decl: + mentioned = re.findall(r'CF_OP_\w+', decl.group(1)) + # It is a variable, so the generated program needs a + # variable of that type rather than the name itself. + op_expr = 'CF_DUMMY_STR' + if not mentioned: + errors.append('%s: %s does not resolve to an operation' + % (where, op)) + continue + for name in mentioned: + if name not in operations: + errors.append('%s: unknown operation %s' + % (where, name)) + + rest = args[1:] + if macro == 'CF_FILEOP_PREPARE': + if not rest: + errors.append('%s: PREPARE with no error argument' % where) + continue + rest = rest[1:] # the GError ** argument + + rewritten = [] + for arg in rest: + m = re.match(r'^\.(\w+)\s*=\s*(.*)$', arg, re.S) + if not m: + errors.append('%s: %r is not a designated initializer' + % (where, arg)) + continue + name = m.group(1) + if name not in fields: + errors.append('%s: CfFileOp has no field %r' + % (where, name)) + continue + if name == 'phase': + errors.append('%s: call sites must not set .phase' + % where) + continue + rewritten.append('.%s = %s' % (name, DUMMY[fields[name]])) + + if macro == 'CF_FILEOP_PREPARE': + body.append(' /* %s */' % where) + body.append(' if (%s (%s, &cf_error%s) < 0) return 1;' + % (macro, op_expr, + (', ' + ', '.join(rewritten)) if rewritten else '')) + else: + body.append(' /* %s */' % where) + body.append(' %s (%s%s);' + % (macro, op_expr, + (', ' + ', '.join(rewritten)) if rewritten else '')) + + if errors: + for err in errors: + print('FAIL %s' % err, file=sys.stderr) + return 1 + + program = '\n'.join([ + '/* Generated by tests/cf-fileop/check-call-sites.py. */', + '#include "cf-fileop.h"', + 'static const char *CF_DUMMY_STR = "x";', + 'static GList *CF_DUMMY_LIST = NULL;', + 'int cf_check_call_sites (void);', + 'int cf_check_call_sites (void)', + '{', + ' GError *cf_error = NULL;', + ' (void)cf_error;', + ] + body + [ + ' return 0;', + '}', + '', + ]) + + with tempfile.TemporaryDirectory() as tmp: + src = os.path.join(tmp, 'call-sites.c') + with open(src, 'w', encoding='utf-8') as fp: + fp.write(program) + + cflags = subprocess.run(['pkg-config', '--cflags', 'glib-2.0'], + capture_output=True, text=True, + check=True).stdout.split() + result = subprocess.run( + ['cc', '-std=c99', '-Wall', '-Wextra', '-Werror', + '-fsyntax-only', src, + '-I%s' % os.path.join(root, 'common'), + '-I%s' % os.path.join(root, 'include')] + cflags, + capture_output=True, text=True) + + if result.returncode != 0: + print(result.stderr, file=sys.stderr) + print('FAIL call sites do not compile', file=sys.stderr) + return 1 + + print('cf-fileop: %d call sites in %s type-check' + % (total, ', '.join(SOURCES))) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/cf-fileop/gen-cases.py b/tests/cf-fileop/gen-cases.py new file mode 100755 index 00000000..c0f27bc0 --- /dev/null +++ b/tests/cf-fileop/gen-cases.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Turn the shared write-lifecycle case set into a C header. + +The C test would otherwise need a JSON parser just to read its fixtures, which +would drag jansson into a test that is meant to depend on nothing but glib -- +the same reason tests/cf-acl generates its own header. Generating keeps the C +side dependency-free while still driving it from the one authoritative file. + +Usage: gen-cases.py path/to/fileop-cases.json > cf-fileop-cases.h +""" + +import json +import sys + + +def c_string(value): + if value is None: + return 'NULL' + escaped = (value.replace('\\', '\\\\') + .replace('"', '\\"')) + # Non-ASCII paths are in the case set on purpose (the "no Unicode + # normalization" cases). Escape them byte-wise so the generated header is + # pure ASCII and cannot be re-normalized by an editor on its way through. + out = [] + for byte in escaped.encode('utf-8'): + if 0x20 <= byte < 0x7f: + out.append(chr(byte)) + else: + out.append('\\x%02x""' % byte) + return '"%s"' % ''.join(out) + + +VERDICTS = {'allow': 0, 'refuse': 1, 'none': 2} +PREPARES = {'allow': 0, 'refuse': 1, 'inactive': 2} + + +def main(): + if len(sys.argv) != 2: + sys.stderr.write(__doc__) + return 2 + + with open(sys.argv[1], encoding='utf-8') as fp: + data = json.load(fp) + + out = sys.stdout + out.write('/* Generated by tests/cf-fileop/gen-cases.py. Do not edit.\n') + out.write(' * Source: cloudfile-docker/docs/fileop-cases.json (version %d)\n' + % data['version']) + out.write(' */\n\n') + out.write('#ifndef CF_FILEOP_CASES_H\n#define CF_FILEOP_CASES_H\n\n') + + # --- normalize ------------------------------------------------------- + out.write('typedef struct { const char *name; const char *dir;\n' + ' const char *entry; const char *expect; } NormalizeCase;\n\n') + cases = data['normalize']['cases'] + out.write('static const NormalizeCase cf_normalize_cases[] = {\n') + for case in cases: + out.write(' { %s, %s, %s, %s },\n' % ( + c_string(case['name']), c_string(case['dir']), + c_string(case['entry']), c_string(case['expect']))) + out.write('};\n') + out.write('#define CF_N_NORMALIZE_CASES %d\n\n' % len(cases)) + + # --- operations ------------------------------------------------------ + out.write('typedef struct { const char *op; int valid; int source;\n' + ' int pathless; int subject_is_root; } OperationCase;\n\n') + cases = data['operations']['cases'] + out.write('static const OperationCase cf_operation_cases[] = {\n') + for case in cases: + out.write(' { %s, %d, %d, %d, %d },\n' % ( + c_string(case['op']), int(case['valid']), int(case['source']), + int(case['pathless']), int(case['subject_is_root']))) + out.write('};\n') + out.write('#define CF_N_OPERATION_CASES %d\n\n' % len(cases)) + + # --- dispatch -------------------------------------------------------- + out.write('typedef struct { const char *name; const int *verdicts;\n' + ' int n_verdicts; int expect_allowed; int expect_ran;\n' + '} DispatchCase;\n\n') + cases = data['dispatch']['cases'] + for index, case in enumerate(cases): + verdicts = [VERDICTS[v] for v in case['verdicts']] + out.write('static const int dispatch%d_verdicts[] = { %s0 };\n' + % (index, ''.join('%d, ' % v for v in verdicts))) + out.write('static const DispatchCase cf_dispatch_cases[] = {\n') + for index, case in enumerate(cases): + out.write(' { %s, dispatch%d_verdicts, %d, %d, %d },\n' % ( + c_string(case['name']), index, len(case['verdicts']), + int(case['expect_allowed']), int(case['expect_ran']))) + out.write('};\n') + out.write('#define CF_N_DISPATCH_CASES %d\n\n' % len(cases)) + + # --- facts ----------------------------------------------------------- + out.write('typedef struct { const char *name; int prepare;\n' + ' int commit_attempts; int succeeded;\n' + ' int expect_committed; int expect_aborted; } FactCase;\n\n') + cases = data['facts']['cases'] + out.write('static const FactCase cf_fact_cases[] = {\n') + for case in cases: + out.write(' { %s, %d, %d, %d, %d, %d },\n' % ( + c_string(case['name']), PREPARES[case['prepare']], + case['commit_attempts'], int(case['succeeded']), + case['expect_committed'], case['expect_aborted'])) + out.write('};\n') + out.write('#define CF_N_FACT_CASES %d\n\n' % len(cases)) + + out.write('#endif /* CF_FILEOP_CASES_H */\n') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/cf-fileop/run.sh b/tests/cf-fileop/run.sh new file mode 100755 index 00000000..c348f446 --- /dev/null +++ b/tests/cf-fileop/run.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# +# Run the C write-lifecycle seam against the shared case set. +# +# Needs only glib, not the full seafile build, because cf-fileop.c and +# cf-path.c are deliberately free of database and session dependencies. +# +# The case file lives in the cloudfile-docker repo. Point CF_FILEOP_CASES at +# it, or check the repos out side by side and the default path works. + +set -e + +here=$(cd "$(dirname "$0")" && pwd) +repo_root=$(cd "$here/../.." && pwd) +workspace=$(dirname "$repo_root") + +cases=${CF_FILEOP_CASES:-$workspace/cloudfile-docker/docs/fileop-cases.json} + +if [[ ! -f $cases ]]; then + echo "shared fileop case set not found at $cases; set CF_FILEOP_CASES" >&2 + exit 1 +fi + +build=$(mktemp -d) +trap 'rm -rf "$build"' EXIT + +python3 "$here/gen-cases.py" "$cases" > "$build/cf-fileop-cases.h" + +cc -std=c99 -Wall -Wextra -Wno-unused-parameter -o "$build/test-cf-fileop" \ + "$here/test-cf-fileop.c" \ + "$repo_root/common/cf-fileop.c" \ + "$repo_root/common/cf-path.c" \ + -I"$repo_root/common" -I"$repo_root/include" -I"$build" \ + $(pkg-config --cflags --libs glib-2.0) + +"$build/test-cf-fileop" + +# The seam's call sites live in server/repo-op.c, which cannot be compiled +# without the full seafile build. This checks the part of them that can be: +# that every CF_FILEOP_* invocation names real struct fields, passes a real +# operation and has the right arity. Those are the mistakes a designated +# initializer makes easy, and the ones a Linux-only build would otherwise be +# the first to catch. +python3 "$here/check-call-sites.py" "$repo_root" diff --git a/tests/cf-fileop/test-cf-fileop.c b/tests/cf-fileop/test-cf-fileop.c new file mode 100644 index 00000000..0ba881ec --- /dev/null +++ b/tests/cf-fileop/test-cf-fileop.c @@ -0,0 +1,376 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +/* + * Run the shared write-lifecycle case set against the C seam. + * + * The same cloudfile-docker/docs/fileop-cases.json drives the Go suite in + * fileserver/cf_fileop_test.go. If a case fails here it must be fixed in the + * spec first, then in both implementations -- never in one of them alone. + * + * Build and run with ./run.sh; it needs nothing but glib, because cf-fileop.c + * and cf-path.c are deliberately free of database and session dependencies. + */ + +#include +#include + +#include "cf-fileop.h" +#include "cf-path.h" +#include "cf-fileop-cases.h" + +static int failures = 0; +static int checks = 0; + +static void +fail (const char *group, const char *name, const char *fmt, ...) +{ + va_list ap; + fprintf (stderr, "FAIL [%s] %s: ", group, name); + va_start (ap, fmt); + vfprintf (stderr, fmt, ap); + va_end (ap); + fprintf (stderr, "\n"); + failures++; +} + +static void +check_str (const char *group, const char *name, + const char *got, const char *expect) +{ + checks++; + if (g_strcmp0 (got, expect) != 0) + fail (group, name, "got \"%s\", expected \"%s\"", + got ? got : "(null)", expect ? expect : "(null)"); +} + +static void +check_int (const char *group, const char *name, const char *what, + int got, int expect) +{ + checks++; + if (got != expect) + fail (group, name, "%s: got %d, expected %d", what, got, expect); +} + +/* ------------------------------------------------------------- normalize */ + +static void +run_normalize (void) +{ + for (int i = 0; i < CF_N_NORMALIZE_CASES; i++) { + const NormalizeCase *c = &cf_normalize_cases[i]; + char *got = cf_path_join (c->dir, c->entry); + check_str ("normalize", c->name, got, c->expect); + g_free (got); + } +} + +/* ------------------------------------------------------------ operations */ + +static void +run_operations (void) +{ + for (int i = 0; i < CF_N_OPERATION_CASES; i++) { + const OperationCase *c = &cf_operation_cases[i]; + check_int ("operations", c->op, "valid", + cf_fileop_op_valid (c->op) ? 1 : 0, c->valid); + check_int ("operations", c->op, "source", + cf_fileop_op_has_source (c->op) ? 1 : 0, c->source); + check_int ("operations", c->op, "pathless", + cf_fileop_op_pathless (c->op) ? 1 : 0, c->pathless); + check_int ("operations", c->op, "subject_is_root", + cf_fileop_op_subject_is_root (c->op) ? 1 : 0, + c->subject_is_root); + } + + /* NULL is not in the case set because JSON cannot express it, and it is + * exactly what a call site with an uninitialised op would pass. */ + check_int ("operations", "(null)", "valid", + cf_fileop_op_valid (NULL) ? 1 : 0, 0); +} + +/* -------------------------------------------------------------- dispatch */ + +/* Verdict codes, matching gen-cases.py. */ +#define V_ALLOW 0 +#define V_REFUSE 1 +#define V_NONE 2 /* provider registered without a prepare hook */ + +static int stub_ran; +static int stub_committed; +static int stub_aborted; + +static int stub_allow (const CfFileOp *fop, GError **error) { stub_ran++; return 0; } +static int stub_refuse (const CfFileOp *fop, GError **error) { + stub_ran++; + g_set_error (error, g_quark_from_string ("seafile"), 600, + "Locked by someone else"); + return -1; +} +static void stub_commit (const CfFileOp *fop) { stub_committed++; } +static void stub_abort (const CfFileOp *fop) { stub_aborted++; } + +static void +register_verdicts (const int *verdicts, int n) +{ + for (int i = 0; i < n; i++) { + switch (verdicts[i]) { + case V_ALLOW: + cf_fileop_register ("stub-allow", stub_allow, NULL, NULL); + break; + case V_REFUSE: + cf_fileop_register ("stub-refuse", stub_refuse, NULL, NULL); + break; + case V_NONE: + cf_fileop_register ("stub-none", NULL, NULL, NULL); + break; + } + } +} + +static void +run_dispatch (void) +{ + for (int i = 0; i < CF_N_DISPATCH_CASES; i++) { + const DispatchCase *c = &cf_dispatch_cases[i]; + + cf_fileop_reset (); + stub_ran = 0; + register_verdicts (c->verdicts, c->n_verdicts); + + CfFileOp fop = { .op = CF_OP_CREATE_FILE, .repo_id = "r", + .dir = "/a", .name = "b.txt", .user = "u" }; + GError *error = NULL; + int rc = cf_fileop_prepare (&fop, &error); + + check_int ("dispatch", c->name, "allowed", rc == 0 ? 1 : 0, + c->expect_allowed); + check_int ("dispatch", c->name, "providers run", stub_ran, + c->expect_ran); + + /* A refusal must always arrive with a reason attached: the message + * reaches the end user, and a refusal nobody can act on becomes a + * support ticket with nothing in it. */ + checks++; + if (rc != 0 && (!error || !error->message)) + fail ("dispatch", c->name, "refused without a GError"); + + g_clear_error (&error); + } + + cf_fileop_reset (); +} + +/* ----------------------------------------------------------------- facts */ + +/* Prepare codes, matching gen-cases.py. */ +#define P_ALLOW 0 +#define P_REFUSE 1 +#define P_INACTIVE 2 + +/* + * Models what a repo-op.c entry point does, so the exactly-once accounting is + * tested rather than merely asserted in prose: + * + * PREPARE -> [commit attempt] * n -> COMMITTED or ABORTED + * + * commit_attempts > 1 is the SEAF_ERR_CONCURRENT_UPLOAD retry loop, and the + * point of the case is that retrying must not multiply the fact. + */ +static void +simulate_entry_point (const FactCase *c) +{ + gboolean cf_prepared = FALSE; + CfFileOp fop = { .op = CF_OP_CREATE_FILE, .repo_id = "r", + .dir = "/a", .name = "b.txt", .user = "u" }; + GError *error = NULL; + + if (cf_fileop_active ()) { + if (cf_fileop_prepare (&fop, &error) < 0) { + g_clear_error (&error); + return; + } + cf_prepared = TRUE; + } + + for (int attempt = 0; attempt < c->commit_attempts; attempt++) { + gboolean last = (attempt == c->commit_attempts - 1); + if (!last) + continue; /* earlier attempts lost the race */ + if (!c->succeeded) + break; + + CF_FILEOP_COMMITTED (CF_OP_CREATE_FILE, .repo_id = "r", + .dir = "/a", .name = "b.txt", .user = "u"); + cf_prepared = FALSE; + } + + if (cf_prepared) + CF_FILEOP_ABORTED (CF_OP_CREATE_FILE, .repo_id = "r", + .dir = "/a", .name = "b.txt", .user = "u"); +} + +static void +run_facts (void) +{ + for (int i = 0; i < CF_N_FACT_CASES; i++) { + const FactCase *c = &cf_fact_cases[i]; + + cf_fileop_reset (); + stub_ran = stub_committed = stub_aborted = 0; + + switch (c->prepare) { + case P_ALLOW: + cf_fileop_register ("stub", stub_allow, stub_commit, stub_abort); + break; + case P_REFUSE: + cf_fileop_register ("stub", stub_refuse, stub_commit, stub_abort); + break; + case P_INACTIVE: + break; /* no provider at all */ + } + + simulate_entry_point (c); + + check_int ("facts", c->name, "COMMITTED", stub_committed, + c->expect_committed); + check_int ("facts", c->name, "ABORTED", stub_aborted, + c->expect_aborted); + } + + cf_fileop_reset (); +} + +/* -------------------------------------------------------------- baseline */ + +static int inactive_calls; + +static int +counting_prepare (const CfFileOp *fop, GError **error) +{ + inactive_calls++; + return 0; +} + +/* + * The iron law, asserted rather than assumed: with nothing registered, the + * seam does not reach a provider, does not allocate a context and does not + * change a return code. + */ +static void +run_baseline (void) +{ + cf_fileop_reset (); + inactive_calls = 0; + + check_int ("baseline", "no provider", "active", + cf_fileop_active () ? 1 : 0, 0); + + CfFileOp fop = { .op = CF_OP_CREATE_FILE, .repo_id = "r", + .dir = "/a", .name = "b.txt", .user = "u" }; + GError *error = NULL; + check_int ("baseline", "no provider", "prepare", + cf_fileop_prepare (&fop, &error), 0); + checks++; + if (error) + fail ("baseline", "no provider", "prepare set an error"); + g_clear_error (&error); + + /* The macros must not even evaluate their arguments when inactive. */ + check_int ("baseline", "no provider", "macro prepare", + CF_FILEOP_PREPARE (CF_OP_CREATE_FILE, &error, + .repo_id = "r", .dir = "/a", + .name = "b.txt", .user = "u"), 0); + CF_FILEOP_COMMITTED (CF_OP_CREATE_FILE, .repo_id = "r", .dir = "/a"); + CF_FILEOP_ABORTED (CF_OP_CREATE_FILE, .repo_id = "r", .dir = "/a"); + check_int ("baseline", "no provider", "provider reached", + inactive_calls, 0); + + /* And with one registered, the macro does reach it -- otherwise the check + * above would pass on a seam that is wired to nothing. */ + cf_fileop_register ("counting", counting_prepare, NULL, NULL); + CF_FILEOP_PREPARE (CF_OP_CREATE_FILE, &error, + .repo_id = "r", .dir = "/a", .name = "b.txt", + .user = "u"); + check_int ("baseline", "with provider", "provider reached", + inactive_calls, 1); + + cf_fileop_reset (); +} + +/* --------------------------------------------------------------- subject */ + +static void +run_subjects (void) +{ + /* Batch paths: one entry per name, joined against the same dir. */ + GList *names = NULL; + names = g_list_append (names, (gpointer)"one.txt"); + names = g_list_append (names, (gpointer)"two.txt"); + + CfFileOp batch = { .op = CF_OP_DELETE, .repo_id = "r", + .dir = "/a", .names = names, .user = "u" }; + GList *paths = cf_fileop_subject_paths (&batch); + check_int ("subject", "batch", "count", g_list_length (paths), 2); + check_str ("subject", "batch first", g_list_nth_data (paths, 0), "/a/one.txt"); + check_str ("subject", "batch second", g_list_nth_data (paths, 1), "/a/two.txt"); + g_list_free_full (paths, g_free); + g_list_free (names); + + /* Single object: one path, from dir + name. */ + CfFileOp single = { .op = CF_OP_UPDATE_FILE, .repo_id = "r", + .dir = "/a", .name = "b.txt", .user = "u" }; + char *one = cf_fileop_subject_path (&single); + check_str ("subject", "single", one, "/a/b.txt"); + g_free (one); + + /* Pathless ops have no subject at all -- a lock provider keys on this. */ + CfFileOp blocks = { .op = CF_OP_UPLOAD_BLOCKS, .repo_id = "r", .user = "u" }; + checks++; + if (cf_fileop_subject_path (&blocks) != NULL) + fail ("subject", "upload-blocks", "expected no subject path"); + checks++; + if (cf_fileop_subject_paths (&blocks) != NULL) + fail ("subject", "upload-blocks", "expected no subject paths"); + + /* Whole-library ops answer "/" regardless of what dir happens to hold. */ + CfFileOp whole = { .op = CF_OP_SYNC_UPDATE, .repo_id = "r", + .dir = "/ignored", .user = "u" }; + char *root = cf_fileop_subject_path (&whole); + check_str ("subject", "sync-update", root, "/"); + g_free (root); + + /* Source paths, for the ops that write both ends. */ + GList *src_names = NULL; + src_names = g_list_append (src_names, (gpointer)"x.txt"); + CfFileOp mv = { .op = CF_OP_MOVE, .repo_id = "r", .dir = "/dst", + .src_repo_id = "r", .src_dir = "/src", + .src_names = src_names, .user = "u" }; + GList *src = cf_fileop_source_paths (&mv); + check_int ("subject", "move source", "count", g_list_length (src), 1); + check_str ("subject", "move source", g_list_nth_data (src, 0), "/src/x.txt"); + g_list_free_full (src, g_free); + g_list_free (src_names); + + /* An op with no source must not invent one. */ + checks++; + if (cf_fileop_source_paths (&single) != NULL) + fail ("subject", "update-file", "expected no source paths"); +} + +/* ------------------------------------------------------------------ main */ + +int +main (void) +{ + run_normalize (); + run_operations (); + run_dispatch (); + run_facts (); + run_baseline (); + run_subjects (); + + printf ("cf-fileop: %d checks, %d failures\n", checks, failures); + + return failures == 0 ? 0 : 1; +} From a322a2aed5a9b60bd5dc3440f3fe2d929529fb20 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Sun, 26 Jul 2026 23:56:55 +0800 Subject: [PATCH 2/4] feat(fileop): add the gate the seam was missing, and fix copy/move facts Two things, both found by taking the exit criteria literally. The defect: copy and move shipped COMMITTED facts with no commit_id. Both commit through put_dirent_and_commit and move_file_same_repo, two static helpers that discarded the id gen_new_commit hands back. A fact without a version cannot be lined up against the repo-update stream, which is where the file change itself is recorded, and the omission is invisible -- the consumer just gets an empty string. Both helpers now take an out param. check-call-sites.py grew a rule for it: every COMMITTED but upload-blocks must carry .commit_id, verified by mutation. The gap: P0.5's one unmet exit criterion was "with no lock implemented yet, a fake provider can already refuse at every write entry point". So here is the fake provider, and the gate that drives it. cf-fileop-test.c journals every event and refuses any operation whose subject or source path has a marked component. It is gated at runtime, not compiled out, because a build flag would mean the gate exercises an image that is not the one shipped -- the same mistake that let a hand-written settings fixture pass while the generated file raised NameError and silently discarded every CloudFile setting. It is deliberately not a CF_ENABLE_* switch: that list is product capabilities an operator may reasonably turn on, and this can refuse writes. Matching is component-wise, not substring or prefix. A substring makes "notes-secret.txt" match a component of "secret", so a refusal meant for one object quietly covers others; a prefix cannot be seeded, because seeding means creating the marked directory and creating it is one of the operations under test. The rule lives in cf-path.c with 15 shared cases rather than private to the provider, since whether phase 2 of the gate passes for the right reason depends on it. The matrix runs in two phases because the fixtures can only be built while refusal is off, and because a provider that refuses everything -- or a broken service -- would make "every entry point refuses" trivially true. Hence the positive controls. 159 C checks, 6 Go contract checks, 50 call sites type-checked, 11 mutations all caught. The end-to-end gate itself has never run: it needs Linux. A written gate is not a green one. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 15 ++- common/cf-ext.c | 10 ++ common/cf-ext.h | 6 + common/cf-fileop-test.c | 199 ++++++++++++++++++++++++++++ common/cf-fileop-test.h | 51 +++++++ common/cf-path.c | 25 ++++ common/cf-path.h | 15 +++ server/Makefile.am | 2 + server/repo-op.c | 33 ++++- tests/cf-fileop/check-call-sites.py | 16 +++ tests/cf-fileop/gen-cases.py | 12 ++ tests/cf-fileop/test-cf-fileop.c | 21 +++ 12 files changed, 396 insertions(+), 9 deletions(-) create mode 100644 common/cf-fileop-test.c create mode 100644 common/cf-fileop-test.h diff --git a/AGENTS.md b/AGENTS.md index 89c78468..e67a53de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,13 +81,22 @@ docker 仓的 bootstrap 找不到该文件时会跳过并告警。 common/cf-ext.{c,h} 读侧扩展点:配置读取 + 能力注册表 + 三个分发钩子 common/cf-fileop.{c,h} 写侧扩展点:PREPARE / COMMITTED / ABORTED common/cf-fileop-json.{c,h} 上面那个的 JSON 线格式(jansson 只出现在这里) -common/cf-path.{c,h} 路径规范化,两个扩展点共用同一份 +common/cf-fileop-test.c 门禁用的假 provider,默认关闭,**不是能力** +common/cf-path.{c,h} 路径规范化与组件匹配,两个扩展点共用同一份 fileserver/cf_ext.go 同步客户端网关,走 RPC 问 seaf-server fileserver/cf_fileop.go Go 写入口网关,同样走 RPC 问 seaf-server ``` -`cf_ext_init()` 里没有注册任何能力,`cf_fileop_register()` 也没人调用,所以基线上 -每个钩子都是透传,行为与原生 CE 完全一致。 +`cf_ext_init()` 里没有注册任何能力,所以基线上每个钩子都是透传,行为与原生 CE +完全一致。唯一的例外是 `cf-fileop-test.c`:它由 `[cloudfile] +fileop_test_provider_enabled` 门控,**默认关闭**,且刻意不进 `CF_ENABLE_*` 清单—— +那份清单里的每一项都是运维可以合理打开的产品能力,而它是写入生命周期门禁用的 +仪器,能拒绝写入、每次写入都追加文件,注册时会打一条明说"不要在生产里跑"的警告。 + +为什么用运行时开关而不是编译期剔除:编译期剔除意味着门禁跑的镜像不是发出去的 +那个。这个项目为此付过一次代价——一份手写的 `seahub_settings.py` fixture 通过了 +测试,而真正生成的文件抛 `NameError`、把整个文件的 CloudFile 配置一起丢掉, +服务看起来还正常起来了。**测发出去的那个。** `cf-fileop.c`、`cf-path.c` 刻意只依赖 glib,因此 `tests/cf-fileop/run.sh` 不需要 完整的 seafile 构建就能跑——与 `cf-acl-resolve.c` 同一条理由。规格见 diff --git a/common/cf-ext.c b/common/cf-ext.c index 1e2c6a0b..0c4ea535 100644 --- a/common/cf-ext.c +++ b/common/cf-ext.c @@ -8,6 +8,7 @@ #include "seafile-session.h" #include "cf-ext.h" #include "cf-acl.h" +#include "cf-fileop-test.h" typedef struct CfProvider { char *name; @@ -26,6 +27,14 @@ cf_ext_config_bool (const char *key) return seaf_cfg_manager_get_config_boolean (seaf->cfg_mgr, "cloudfile", key); } +char * +cf_ext_config_string (const char *key) +{ + if (!seaf || !seaf->cfg_mgr) + return NULL; + return seaf_cfg_manager_get_config_string (seaf->cfg_mgr, "cloudfile", key); +} + gboolean cf_ext_active (void) { @@ -61,6 +70,7 @@ cf_ext_init (void) * this file is CloudFile's own, so editing it costs nothing at sync time. */ cf_acl_init (); + cf_fileop_test_init (); } char * diff --git a/common/cf-ext.h b/common/cf-ext.h index 9267a5d8..c4120fc2 100644 --- a/common/cf-ext.h +++ b/common/cf-ext.h @@ -42,6 +42,12 @@ void cf_ext_init (void); */ gboolean cf_ext_config_bool (const char *key); +/* A [cloudfile] string key, or NULL when unset. Newly allocated; caller + * frees. Same reason as the boolean above: one place that knows where + * capability configuration lives. + */ +char *cf_ext_config_string (const char *key); + /* Whether any capability has registered. Lets callers skip work entirely on a * plain CE deployment. */ diff --git a/common/cf-fileop-test.c b/common/cf-fileop-test.c new file mode 100644 index 00000000..11e1d316 --- /dev/null +++ b/common/cf-fileop-test.c @@ -0,0 +1,199 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +#include +#include +#include +#include + +#include "log.h" +#include "seafile-error.h" +#include "cf-ext.h" +#include "cf-path.h" +#include "cf-fileop.h" +#include "cf-fileop-test.h" + +/* Deliberately no seafile-session.h: everything this needs about the running + * server comes through cf_ext_config_*, so the file stays compilable on its + * own -- same property as cf-fileop.c and cf-path.c, and the reason the seam + * can be tested without the seafile build. */ + +static char *refuse_token; +static char *journal_path; + +/* The journal is appended to from every write path, so it needs its own lock: + * seaf-server handles requests on a thread pool, and interleaved partial lines + * would make the matrix's counts meaningless in a way that looks like a + * seam bug rather than a test bug. */ +static GMutex journal_lock; + +static const char * +phase_name (CfFileOpPhase phase) +{ + switch (phase) { + case CF_FILEOP_PHASE_PREPARE: return "PREPARE"; + case CF_FILEOP_PHASE_COMMITTED: return "COMMITTED"; + case CF_FILEOP_PHASE_ABORTED: return "ABORTED"; + } + return "UNKNOWN"; +} + +/* The matching rule itself lives in cf-path.c, so it is covered by the shared + * case set rather than being private to this file. Whether phase 2 of the gate + * passes for the right reason depends on it: substring matching would make the + * refusal cover paths the test never named. */ +static gboolean +any_marked (GList *paths) +{ + GList *ptr; + for (ptr = paths; ptr; ptr = ptr->next) { + if (cf_path_has_component (ptr->data, refuse_token)) + return TRUE; + } + return FALSE; +} + +static char * +join_paths (GList *paths) +{ + if (!paths) + return g_strdup ("-"); + + GString *buf = g_string_new (""); + GList *ptr; + + for (ptr = paths; ptr; ptr = ptr->next) { + if (buf->len) + g_string_append_c (buf, ','); + g_string_append (buf, (char *)ptr->data); + } + + return g_string_free (buf, FALSE); +} + +/* + * One line per event, space separated, fields in a fixed order so the matrix + * can parse it without a JSON dependency inside the container: + * + * + * + * Every field is present on every line; "-" stands for absent. A format where + * fields disappear when empty would let a missing field read as a shifted one. + */ +static void +journal (const CfFileOp *fop) +{ + if (!journal_path) + return; + + GList *subjects = cf_fileop_subject_paths (fop); + GList *sources = cf_fileop_source_paths (fop); + char *subject_str = join_paths (subjects); + char *source_str = join_paths (sources); + + g_mutex_lock (&journal_lock); + + FILE *fp = g_fopen (journal_path, "a"); + if (fp) { + fprintf (fp, "%s %s %s %s %s %s %s\n", + phase_name (fop->phase), + fop->op ? fop->op : "-", + fop->repo_id ? fop->repo_id : "-", + subject_str, + source_str, + fop->user ? fop->user : "-", + (fop->commit_id && *fop->commit_id) ? fop->commit_id : "-"); + fclose (fp); + } else { + /* Warn rather than fail the write: this provider must not be able to + * turn a full disk into a service outage, even in a test deployment. */ + seaf_warning ("CloudFile fileop test: cannot append to %s\n", + journal_path); + } + + g_mutex_unlock (&journal_lock); + + g_free (subject_str); + g_free (source_str); + g_list_free_full (subjects, g_free); + g_list_free_full (sources, g_free); +} + +static int +test_prepare (const CfFileOp *fop, GError **error) +{ + journal (fop); + + /* Pathless operations have no subject, so there is nothing for a token to + * match. Refusing them would be refusing at random -- exactly what the + * contract tells a lock provider not to do -- and it would also break + * every chunked upload in the matrix before it reached the entry point + * actually under test. */ + if (cf_fileop_op_pathless (fop->op)) + return 0; + + GList *subjects = cf_fileop_subject_paths (fop); + GList *sources = cf_fileop_source_paths (fop); + gboolean refuse = any_marked (subjects) || any_marked (sources); + char *subject_str = refuse ? join_paths (subjects) : NULL; + + g_list_free_full (subjects, g_free); + g_list_free_full (sources, g_free); + + if (!refuse) + return 0; + + /* CF_ERR_FILE_LOCKED so the matrix can check the whole chain, including + * that Go maps it to 423 and that Seahub does not flatten it to a 500. */ + g_set_error (error, SEAFILE_DOMAIN, CF_ERR_FILE_LOCKED, + "CloudFile fileop test provider refused %s on %s", + fop->op, subject_str); + g_free (subject_str); + + return -1; +} + +static void +test_committed (const CfFileOp *fop) +{ + journal (fop); +} + +static void +test_aborted (const CfFileOp *fop) +{ + journal (fop); +} + +void +cf_fileop_test_init (void) +{ + if (!cf_ext_config_bool ("fileop_test_provider_enabled")) + return; + + refuse_token = cf_ext_config_string ("fileop_test_refuse_token"); + if (refuse_token && !*refuse_token) { + g_free (refuse_token); + refuse_token = NULL; + } + + journal_path = cf_ext_config_string ("fileop_test_journal"); + if (journal_path && !*journal_path) { + g_free (journal_path); + journal_path = NULL; + } + + g_mutex_init (&journal_lock); + + cf_fileop_register ("fileop-test", test_prepare, test_committed, + test_aborted); + + /* Loud on purpose. This provider can refuse writes and it appends to a + * file on every one of them; it exists to gate the seam and has no place + * in a production deployment. */ + seaf_warning ("CloudFile: fileop TEST provider is enabled " + "(refuse_token=%s, journal=%s). " + "This is for the write lifecycle gate only -- " + "do not run it in production.\n", + refuse_token ? refuse_token : "(none)", + journal_path ? journal_path : "(none)"); +} diff --git a/common/cf-fileop-test.h b/common/cf-fileop-test.h new file mode 100644 index 00000000..87067bf9 --- /dev/null +++ b/common/cf-fileop-test.h @@ -0,0 +1,51 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +/* + * A deliberately dumb write lifecycle provider, for gating the seam itself. + * + * Why this exists + * + * The seam's exit criterion is "with no lock implemented yet, a fake provider + * can already refuse at every write entry point". Without something to + * register, the only evidence the seam is reached at runtime would be the + * absence of symptoms -- and the directory ACL already showed what that is + * worth: 62 C checks and 87 Python checks all passed while the rules being + * stored could never match, and the defect only appeared when the stack came + * up and another user's token hit each entry point. + * + * So this is not a stub standing in for missing work. It is the instrument + * that proves the wiring, and it stays useful after the lock lands: a lock + * refuses for reasons that depend on lock state, which makes it a poor probe + * for "was this call site reached at all". + * + * Why it is gated at runtime rather than compiled out + * + * A build flag would mean the end-to-end gate exercises an image that is not + * the one shipped, and this project has already paid for that mistake once -- + * a Django settings test whose fixture was hand-written rather than generated + * passed while the real generated file raised NameError and silently discarded + * every CloudFile setting. Test what ships. + * + * The cost of that choice is a switch that must never be on in production. + * It is off unless [cloudfile] fileop_test_provider_enabled is explicitly + * true, and registering it logs a warning that says so. + * + * Configuration, all in the [cloudfile] section of seafile.conf: + * + * fileop_test_provider_enabled off unless true + * fileop_test_refuse_token refuse any operation whose subject or + * source path contains this path component; + * empty means never refuse + * fileop_test_journal append one line per event here; empty + * means do not journal + * + * Contract: cloudfile-docker/docs/fileop-lifecycle.md + */ + +#ifndef CF_FILEOP_TEST_H +#define CF_FILEOP_TEST_H + +/* Registers the provider iff its switch is on. Called from cf_ext_init(). */ +void cf_fileop_test_init (void); + +#endif /* CF_FILEOP_TEST_H */ diff --git a/common/cf-path.c b/common/cf-path.c index 33ff0b01..7f4aff6a 100644 --- a/common/cf-path.c +++ b/common/cf-path.c @@ -47,3 +47,28 @@ cf_path_join (const char *dir, const char *entry) return norm; } + +gboolean +cf_path_has_component (const char *path, const char *component) +{ + if (!path || !component || !*component) + return FALSE; + + size_t len = strlen (component); + const char *p = path; + + while (*p) { + while (*p == '/') + p++; + if (!*p) + break; + const char *start = p; + while (*p && *p != '/') + p++; + if ((size_t)(p - start) == len && + strncmp (start, component, len) == 0) + return TRUE; + } + + return FALSE; +} diff --git a/common/cf-path.h b/common/cf-path.h index f3069681..1f2d3dfe 100644 --- a/common/cf-path.h +++ b/common/cf-path.h @@ -50,4 +50,19 @@ char *cf_path_normalize (const char *path); */ char *cf_path_join (const char *dir, const char *entry); +/* + * Whether @path has @component as one of its slash-separated components. + * + * Component-wise, deliberately not a substring or a prefix match: + * + * - A substring makes "notes-secret.txt" match a component of "secret", so a + * rule meant for one object quietly covers others. + * - A prefix cannot express "anywhere below", and it cannot be seeded by a + * test that has to create the marked directory first. + * + * Empty or NULL @component never matches -- that is how "no marker configured" + * is spelled, and it must not degenerate into "matches everything". + */ +gboolean cf_path_has_component (const char *path, const char *component); + #endif /* CF_PATH_H */ diff --git a/server/Makefile.am b/server/Makefile.am index 63628eb3..8c797fa5 100644 --- a/server/Makefile.am +++ b/server/Makefile.am @@ -40,6 +40,7 @@ noinst_HEADERS = web-accesstoken-mgr.h seafile-session.h \ ../common/cf-ext.h \ ../common/cf-fileop.h \ ../common/cf-fileop-json.h \ + ../common/cf-fileop-test.h \ ../common/cf-path.h \ ../common/cf-acl.h \ ../common/cf-acl-resolve.h \ @@ -96,6 +97,7 @@ seaf_server_SOURCES = \ ../common/cf-ext.c \ ../common/cf-fileop.c \ ../common/cf-fileop-json.c \ + ../common/cf-fileop-test.c \ ../common/cf-path.c \ ../common/cf-acl.c \ ../common/cf-acl-resolve.c \ diff --git a/server/repo-op.c b/server/repo-op.c index 78affab3..85e48a2c 100644 --- a/server/repo-op.c +++ b/server/repo-op.c @@ -2281,6 +2281,12 @@ get_dirent_by_path (SeafRepo *repo, return dent; } +/* + * @new_commit_id: optional 41-byte out buffer for the resulting commit id. + * CloudFile needs it so the COMMITTED fact for a copy or a move carries the + * version it produced -- without it a consumer cannot line the event up with + * the repo-update stream, which is where the file change itself is recorded. + */ static int put_dirent_and_commit (SeafRepo *repo, const char *path, @@ -2290,6 +2296,7 @@ put_dirent_and_commit (SeafRepo *repo, const char *user, gboolean check_gc, const char *last_gc_id, + char *new_commit_id, GError **error) { SeafCommit *head_commit = NULL; @@ -2337,7 +2344,7 @@ put_dirent_and_commit (SeafRepo *repo, } if (gen_new_commit (repo->id, head_commit, root_id, - user, buf, NULL, TRUE, check_gc, last_gc_id, error) < 0) + user, buf, new_commit_id, TRUE, check_gc, last_gc_id, error) < 0) ret = -1; out: @@ -2904,6 +2911,7 @@ cross_repo_copy (const char *src_repo_id, modifier, TRUE, gc_id, + NULL, NULL) < 0) { err_str = COPY_ERR_INTERNAL; ret = -1; @@ -3041,6 +3049,7 @@ seaf_repo_manager_copy_file (SeafRepoManager *mgr, char *task_id = NULL; SeafileCopyResult *res= NULL; gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; GET_REPO_OR_FAIL(src_repo, src_repo_id); @@ -3113,6 +3122,7 @@ seaf_repo_manager_copy_file (SeafRepoManager *mgr, user, FALSE, NULL, + cf_commit_id, error) < 0) { if (!error) g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_GENERAL, @@ -3165,11 +3175,14 @@ seaf_repo_manager_copy_file (SeafRepoManager *mgr, /* The background branch reports the fact once the copy is scheduled: the * task runs cross_repo_copy, which commits through this same file, so a * second COMMITTED would double-count. */ + /* Empty for the cross-repo branches: those commit inside the copy task, + * which runs through this same file and reports its own fact. */ CF_FILEOP_COMMITTED (CF_OP_COPY, .repo_id = dst_repo_id, .dir = dst_canon_path, .name = dst_filename, .src_repo_id = src_repo_id, .src_dir = src_canon_path, - .src_name = src_filename, .user = user); + .src_name = src_filename, .user = user, + .commit_id = cf_commit_id); cf_prepared = FALSE; out: @@ -3236,6 +3249,7 @@ seaf_repo_manager_copy_multiple_files (SeafRepoManager *mgr, SeafileCopyResult *res = NULL; GHashTable *dirent_hash = NULL; gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; GET_REPO_OR_FAIL(src_repo, src_repo_id); @@ -3342,6 +3356,7 @@ seaf_repo_manager_copy_multiple_files (SeafRepoManager *mgr, user, FALSE, NULL, + cf_commit_id, error) < 0) { if (!error) g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_GENERAL, @@ -3399,7 +3414,7 @@ seaf_repo_manager_copy_multiple_files (SeafRepoManager *mgr, .repo_id = dst_repo_id, .dir = dst_canon_path, .names = dst_names, .src_repo_id = src_repo_id, .src_dir = src_canon_path, - .user = user); + .user = user, .commit_id = cf_commit_id); cf_prepared = FALSE; out: @@ -3450,6 +3465,7 @@ move_file_same_repo (const char *repo_id, int file_num, int replace, const char *user, + char *new_commit_id, GError **error) { SeafRepo *repo = NULL; @@ -3504,7 +3520,7 @@ move_file_same_repo (const char *repo_id, } if (gen_new_commit (repo_id, head_commit, root_id, - user, buf, NULL, TRUE, TRUE, gc_id, error) < 0) + user, buf, new_commit_id, TRUE, TRUE, gc_id, error) < 0) ret = -1; out: @@ -3682,6 +3698,7 @@ cross_repo_move (const char *src_repo_id, modifier, TRUE, gc_id, + NULL, NULL) < 0) { err_str = COPY_ERR_INTERNAL; ret = -1; @@ -3799,6 +3816,7 @@ seaf_repo_manager_move_multiple_files (SeafRepoManager *mgr, SeafileCopyResult *res = NULL; GHashTable *dirent_hash = NULL; gboolean cf_prepared = FALSE; + char cf_commit_id[41] = ""; GET_REPO_OR_FAIL(src_repo, src_repo_id); @@ -3906,7 +3924,8 @@ seaf_repo_manager_move_multiple_files (SeafRepoManager *mgr, src_filenames, src_canon_path, src_dents, dst_canon_path, dst_dents, - file_num, replace, user, error) < 0) { + file_num, replace, user, + cf_commit_id, error) < 0) { ret = -1; goto out; } @@ -3920,6 +3939,7 @@ seaf_repo_manager_move_multiple_files (SeafRepoManager *mgr, user, FALSE, NULL, + cf_commit_id, NULL) < 0) { ret = -1; goto out; @@ -3981,7 +4001,8 @@ seaf_repo_manager_move_multiple_files (SeafRepoManager *mgr, .repo_id = dst_repo_id, .dir = dst_canon_path, .names = dst_names, .src_repo_id = src_repo_id, .src_dir = src_canon_path, - .src_names = src_names, .user = user); + .src_names = src_names, .user = user, + .commit_id = cf_commit_id); cf_prepared = FALSE; out: diff --git a/tests/cf-fileop/check-call-sites.py b/tests/cf-fileop/check-call-sites.py index c6c60231..5706ccd9 100755 --- a/tests/cf-fileop/check-call-sites.py +++ b/tests/cf-fileop/check-call-sites.py @@ -154,6 +154,7 @@ def main(): continue rest = rest[1:] # the GError ** argument + given = set() rewritten = [] for arg in rest: m = re.match(r'^\.(\w+)\s*=\s*(.*)$', arg, re.S) @@ -170,8 +171,23 @@ def main(): errors.append('%s: call sites must not set .phase' % where) continue + given.add(name) rewritten.append('.%s = %s' % (name, DUMMY[fields[name]])) + # Every operation but upload-blocks produces a commit, so its + # COMMITTED must say which one. A fact without a version cannot be + # lined up against the repo-update stream, and the omission is + # invisible: the consumer just gets an empty string. + # + # This rule exists because it happened: copy and move commit through + # put_dirent_and_commit and move_file_same_repo, two static helpers + # that used to swallow the id, so both facts shipped without one. + if (macro == 'CF_FILEOP_COMMITTED' + and 'CF_OP_UPLOAD_BLOCKS' not in op + and 'commit_id' not in given): + errors.append('%s: COMMITTED for %s without .commit_id' + % (where, op)) + if macro == 'CF_FILEOP_PREPARE': body.append(' /* %s */' % where) body.append(' if (%s (%s, &cf_error%s) < 0) return 1;' diff --git a/tests/cf-fileop/gen-cases.py b/tests/cf-fileop/gen-cases.py index c0f27bc0..38046d33 100755 --- a/tests/cf-fileop/gen-cases.py +++ b/tests/cf-fileop/gen-cases.py @@ -73,6 +73,18 @@ def main(): out.write('};\n') out.write('#define CF_N_OPERATION_CASES %d\n\n' % len(cases)) + # --- has_component --------------------------------------------------- + out.write('typedef struct { const char *name; const char *path;\n' + ' const char *component; int expect; } ComponentCase;\n\n') + cases = data['has_component']['cases'] + out.write('static const ComponentCase cf_component_cases[] = {\n') + for case in cases: + out.write(' { %s, %s, %s, %d },\n' % ( + c_string(case['name']), c_string(case['path']), + c_string(case['component']), int(case['expect']))) + out.write('};\n') + out.write('#define CF_N_COMPONENT_CASES %d\n\n' % len(cases)) + # --- dispatch -------------------------------------------------------- out.write('typedef struct { const char *name; const int *verdicts;\n' ' int n_verdicts; int expect_allowed; int expect_ran;\n' diff --git a/tests/cf-fileop/test-cf-fileop.c b/tests/cf-fileop/test-cf-fileop.c index 0ba881ec..c1724652 100644 --- a/tests/cf-fileop/test-cf-fileop.c +++ b/tests/cf-fileop/test-cf-fileop.c @@ -65,6 +65,26 @@ run_normalize (void) } } +/* --------------------------------------------------------- has_component */ + +/* + * The write lifecycle gate's fake provider refuses on a marked path component, + * so whether phase 2 of that gate passes for the right reason lives here. + * Substring matching would make one refusal cover paths the test never named, + * and an empty component matching everything would make it cover all of them -- + * either way the gate would be green while proving something else. + */ +static void +run_components (void) +{ + for (int i = 0; i < CF_N_COMPONENT_CASES; i++) { + const ComponentCase *c = &cf_component_cases[i]; + check_int ("has_component", c->name, "match", + cf_path_has_component (c->path, c->component) ? 1 : 0, + c->expect); + } +} + /* ------------------------------------------------------------ operations */ static void @@ -364,6 +384,7 @@ int main (void) { run_normalize (); + run_components (); run_operations (); run_dispatch (); run_facts (); From e899f27cc8671935b402e53b5006c81694b97845 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Thu, 6 Aug 2026 00:49:05 +0800 Subject: [PATCH 3/4] feat(file-lock): add lease-backed checkout authority --- .gitignore | 1 + common/cf-ext.c | 2 + common/cf-lock.c | 427 ++++++++++++++++++++++++++++++++ common/cf-lock.h | 22 ++ common/rpc-service.c | 40 +++ include/seafile-rpc.h | 11 + python/seafile/rpcclient.py | 14 ++ python/seaserv/api.py | 9 + scripts/sql/mysql/cloudfile.sql | 39 +++ server/Makefile.am | 2 + server/seaf-server.c | 15 ++ 11 files changed, 582 insertions(+) create mode 100644 common/cf-lock.c create mode 100644 common/cf-lock.h diff --git a/.gitignore b/.gitignore index b49a1966..909a7751 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ tests/conf/PeerMgr /symbols __pycache__/ .cache/ +.codegraph diff --git a/common/cf-ext.c b/common/cf-ext.c index 0c4ea535..ca75acbb 100644 --- a/common/cf-ext.c +++ b/common/cf-ext.c @@ -9,6 +9,7 @@ #include "cf-ext.h" #include "cf-acl.h" #include "cf-fileop-test.h" +#include "cf-lock.h" typedef struct CfProvider { char *name; @@ -71,6 +72,7 @@ cf_ext_init (void) */ cf_acl_init (); cf_fileop_test_init (); + cf_lock_init (); } char * diff --git a/common/cf-lock.c b/common/cf-lock.c new file mode 100644 index 00000000..df4e756b --- /dev/null +++ b/common/cf-lock.c @@ -0,0 +1,427 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +/* + * The lock truth deliberately lives in seafile-db: both seaf-server and the + * Go fileserver consult this provider through the shared write lifecycle. + * Seahub only asks these JSON adapters to create and release leases; it never + * keeps an advisory copy that could be bypassed by sync or WebDAV. + */ + +#include +#include +#include + +#include "common.h" +#include "cf-ext.h" +#include "cf-fileop.h" +#include "cf-lock.h" +#include "cf-path.h" +#include "log.h" +#include "seaf-db.h" +#include "seafile-error.h" +#include "seafile-session.h" + +static gboolean lock_on = FALSE; + +static int cf_lock_prepare (const CfFileOp *fop, GError **error); + +typedef struct CfLockRow { + char *lock_id; + char *generation; + char *owner; + char *kind; + gint64 lease_until; + gint64 hard_expire_at; + char *status; +} CfLockRow; + +static void +lock_row_clear (CfLockRow *row) +{ + g_free (row->lock_id); + g_free (row->generation); + g_free (row->owner); + g_free (row->kind); + g_free (row->status); + memset (row, 0, sizeof (*row)); +} + +static gboolean +load_lock_row_cb (SeafDBRow *db_row, void *data) +{ + CfLockRow *row = data; + row->lock_id = g_strdup (seaf_db_row_get_column_text (db_row, 0)); + row->generation = g_strdup (seaf_db_row_get_column_text (db_row, 1)); + row->owner = g_strdup (seaf_db_row_get_column_text (db_row, 2)); + row->kind = g_strdup (seaf_db_row_get_column_text (db_row, 3)); + row->lease_until = seaf_db_row_get_column_int64 (db_row, 4); + row->hard_expire_at = seaf_db_row_get_column_int64 (db_row, 5); + row->status = g_strdup (seaf_db_row_get_column_text (db_row, 6)); + return FALSE; +} + +static int +load_lock_row (const char *repo_id, const char *path, CfLockRow *row) +{ + char *path_hash = g_compute_checksum_for_string (G_CHECKSUM_SHA1, path, -1); + int ret = seaf_db_statement_foreach_row ( + seaf->db, + "SELECT lock_id, generation, owner, kind, lease_until, hard_expire_at, status " + "FROM cf_lock_lease WHERE repo_id=? AND path_hash=? AND normalized_path=?", + load_lock_row_cb, row, 3, "string", repo_id, "string", path_hash, + "string", path); + g_free (path_hash); + return ret; +} + +static char * +like_descendant_pattern (const char *path) +{ + if (strcmp (path, "/") == 0) + return g_strdup ("/%"); + GString *pattern = g_string_new (""); + const char *ptr; + for (ptr = path; *ptr; ptr++) { + if (*ptr == '\\' || *ptr == '%' || *ptr == '_') + g_string_append_c (pattern, '\\'); + g_string_append_c (pattern, *ptr); + } + g_string_append (pattern, "/%"); + return g_string_free (pattern, FALSE); +} + +static int +load_descendant_lock_row (const char *repo_id, const char *path, CfLockRow *row) +{ + char *pattern = like_descendant_pattern (path); + int ret = seaf_db_statement_foreach_row ( + seaf->db, + "SELECT lock_id, generation, owner, kind, lease_until, hard_expire_at, status " + "FROM cf_lock_lease WHERE repo_id=? AND normalized_path LIKE ? ESCAPE '\\\\' " + "ORDER BY normalized_path LIMIT 1", + load_lock_row_cb, row, 2, "string", repo_id, "string", pattern); + g_free (pattern); + return ret; +} + +static gboolean +lock_is_live (const CfLockRow *row, gint64 now) +{ + return row->status && strcmp (row->status, "active") == 0 && + row->lease_until > now && row->hard_expire_at > now; +} + +static const char * +json_string (json_t *obj, const char *name) +{ + json_t *value = json_object_get (obj, name); + if (!value || !json_is_string (value)) + return NULL; + const char *str = json_string_value (value); + return str && *str ? str : NULL; +} + +static gint64 +json_seconds (json_t *obj, const char *name, gint64 fallback, gint64 maximum) +{ + json_t *value = json_object_get (obj, name); + if (!value || !json_is_integer (value)) + return fallback; + gint64 seconds = json_integer_value (value); + if (seconds < 30) + return fallback; + return MIN (seconds, maximum); +} + +static char * +dump_response (json_t *response) +{ + char *raw = json_dumps (response, JSON_COMPACT); + char *out = g_strdup (raw ? raw : "{\"ok\":false,\"reason\":\"serialization_failed\"}"); + free (raw); + json_decref (response); + return out; +} + +static char * +bad_request (GError **error, const char *message) +{ + g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_BAD_ARGS, "%s", message); + return NULL; +} + +static json_t * +parse_request (const char *request_json, GError **error) +{ + json_error_t json_error; + json_t *request = json_loadb (request_json ? request_json : "", request_json ? strlen (request_json) : 0, + 0, &json_error); + if (!request || !json_is_object (request)) { + if (request) + json_decref (request); + bad_request (error, "Malformed CloudFile lock request"); + return NULL; + } + return request; +} + +static gboolean +request_object (json_t *request, const char **repo_id, char **path, GError **error) +{ + *repo_id = json_string (request, "repo_id"); + const char *raw_path = json_string (request, "path"); + if (!*repo_id || !raw_path) { + bad_request (error, "repo_id and path are required"); + return FALSE; + } + *path = cf_path_normalize (raw_path); + if (strcmp (*path, "/") == 0) { + g_free (*path); + *path = NULL; + bad_request (error, "A file path is required"); + return FALSE; + } + return TRUE; +} + +gboolean +cf_lock_enabled (void) +{ + return lock_on; +} + +void +cf_lock_init (void) +{ + char *backend = cf_ext_config_string ("lock_backend"); + lock_on = cf_ext_config_bool ("file_lock_enabled") && + (!backend || strcmp (backend, "cloudfile") == 0); + if (backend && strcmp (backend, "cloudfile") != 0) { + seaf_warning ("CloudFile: refusing non-CloudFile lock backend '%s' in CE.\n", backend); + lock_on = FALSE; + } + g_free (backend); + if (!lock_on) + return; + + cf_fileop_register ("file-lock", cf_lock_prepare, NULL, NULL); + seaf_message ("CloudFile: lease-backed file lock provider enabled.\n"); +} + +/* Return the first active lock on a changed object or beneath a changed + * directory. Prefix matching is confined to write preparation, never reused + * by read-side permission checks. */ +static int +check_path (const char *repo_id, const char *path, const char *user, GError **error) +{ + CfLockRow row = {0}; + gint64 now = (gint64)time (NULL); + int ret = load_lock_row (repo_id, path, &row); + if (ret < 0) { + seaf_warning ("CloudFile: cannot read file lock for %s:%s.\n", repo_id, path); + g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_GENERAL, "File lock service unavailable"); + return -1; + } + if (row.lock_id && lock_is_live (&row, now) && + (!user || !row.owner || strcmp (user, row.owner) != 0)) + goto locked; + + /* A directory operation must also respect a lock on any descendant. */ + lock_row_clear (&row); + ret = load_descendant_lock_row (repo_id, path, &row); + if (ret < 0) { + seaf_warning ("CloudFile: cannot read descendant file locks for %s:%s.\n", repo_id, path); + g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_GENERAL, "File lock service unavailable"); + return -1; + } + if (!row.lock_id || !lock_is_live (&row, now) || + (user && row.owner && strcmp (user, row.owner) == 0)) { + lock_row_clear (&row); + return 0; + } + +locked: + g_set_error (error, SEAFILE_DOMAIN, CF_ERR_FILE_LOCKED, + "File %s is locked by %s", path, row.owner ? row.owner : "another user"); + lock_row_clear (&row); + return -1; +} + +static int +cf_lock_prepare (const CfFileOp *fop, GError **error) +{ + if (!lock_on || cf_fileop_op_pathless (fop->op) || !fop->repo_id) + return 0; + GList *subjects = cf_fileop_subject_paths (fop); + GList *sources = cf_fileop_source_paths (fop); + GList *ptr; + int ret = 0; + for (ptr = subjects; ptr && ret == 0; ptr = ptr->next) + ret = check_path (fop->repo_id, ptr->data, fop->user, error); + const char *source_repo_id = fop->src_repo_id ? fop->src_repo_id : fop->repo_id; + for (ptr = sources; ptr && ret == 0; ptr = ptr->next) + ret = check_path (source_repo_id, ptr->data, fop->user, error); + g_list_free_full (subjects, g_free); + g_list_free_full (sources, g_free); + return ret; +} + +char * +cf_lock_status_json (const char *request_json, GError **error) +{ + json_t *request = parse_request (request_json, error); + if (!request) + return NULL; + const char *repo_id; + char *path = NULL; + if (!request_object (request, &repo_id, &path, error)) { + json_decref (request); + return NULL; + } + CfLockRow row = {0}; + int ret = load_lock_row (repo_id, path, &row); + json_t *response = json_object (); + json_object_set_new (response, "ok", json_boolean (ret == 0)); + if (ret != 0) { + json_object_set_new (response, "reason", json_string ("lock_service_unavailable")); + json_object_set_new (response, "locked", json_false ()); + } else if (lock_is_live (&row, (gint64)time (NULL))) { + json_object_set_new (response, "locked", json_true ()); + json_object_set_new (response, "owner", json_string (row.owner)); + json_object_set_new (response, "kind", json_string (row.kind)); + json_object_set_new (response, "generation", json_string (row.generation)); + json_object_set_new (response, "lease_until", json_integer (row.lease_until)); + } else { + json_object_set_new (response, "locked", json_false ()); + } + lock_row_clear (&row); + g_free (path); + json_decref (request); + return dump_response (response); +} + +char * +cf_lock_acquire_json (const char *request_json, GError **error) +{ + json_t *request = parse_request (request_json, error); + if (!request) + return NULL; + const char *repo_id, *owner = json_string (request, "owner"); + const char *kind = json_string (request, "kind"); + char *path = NULL; + if (!owner || !kind || !request_object (request, &repo_id, &path, error)) { + g_free (path); + json_decref (request); + return bad_request (error, "owner, kind, repo_id and path are required"); + } + if (strcmp (kind, "checkout") && strcmp (kind, "local-edit") && + strcmp (kind, "onlyoffice") && strcmp (kind, "pro-compatible")) { + g_free (path); json_decref (request); + return bad_request (error, "Unsupported lock kind"); + } + + gint64 now = (gint64)time (NULL); + gint64 lease_seconds = json_seconds (request, "lease_seconds", 1800, 72 * 3600); + gint64 hard_seconds = json_seconds (request, "hard_expire_seconds", 72 * 3600, 7 * 24 * 3600); + char *lock_id = g_uuid_string_random (); + char *generation = g_uuid_string_random (); + char *path_hash = g_compute_checksum_for_string (G_CHECKSUM_SHA1, path, -1); + SeafDBTrans *trans = seaf_db_begin_transaction (seaf->db); + if (!trans) { + g_free (lock_id); g_free (generation); g_free (path_hash); g_free (path); json_decref (request); + return bad_request (error, "File lock service unavailable"); + } + int ret = seaf_db_trans_query (trans, + "INSERT INTO cf_lock_lease (repo_id, normalized_path, path_hash, lock_id, generation, owner, kind, status, lease_until, hard_expire_at, created_at, updated_at) " + "VALUES (?, ?, ?, '', '', '', '', 'released', 0, 0, ?, ?) " + "ON DUPLICATE KEY UPDATE repo_id=VALUES(repo_id)", + 5, "string", repo_id, "string", path, "string", path_hash, "int64", now, "int64", now); + CfLockRow row = {0}; + if (ret == 0) + ret = seaf_db_trans_foreach_selected_row (trans, + "SELECT lock_id, generation, owner, kind, lease_until, hard_expire_at, status " + "FROM cf_lock_lease WHERE repo_id=? AND path_hash=? AND normalized_path=? FOR UPDATE", + load_lock_row_cb, &row, 3, "string", repo_id, "string", path_hash, "string", path); + gboolean conflict = ret == 0 && lock_is_live (&row, now); + if (ret == 0 && !conflict) + ret = seaf_db_trans_query (trans, + "UPDATE cf_lock_lease SET lock_id=?, generation=?, owner=?, kind=?, status='active', lease_until=?, hard_expire_at=?, last_heartbeat_at=?, updated_at=? WHERE repo_id=? AND path_hash=? AND normalized_path=?", + 11, "string", lock_id, "string", generation, "string", owner, "string", kind, + "int64", now + lease_seconds, "int64", now + hard_seconds, "int64", now, "int64", now, + "string", repo_id, "string", path_hash, "string", path); + if (ret == 0 && !conflict) + ret = seaf_db_trans_query (trans, + "INSERT INTO cf_lock_repo_revision (repo_id, revision, updated_at) VALUES (?, 1, ?) " + "ON DUPLICATE KEY UPDATE revision=revision+1, updated_at=VALUES(updated_at)", + 2, "string", repo_id, "int64", now); + if (ret == 0 && !conflict) + ret = seaf_db_commit (trans); + else + seaf_db_rollback (trans); + seaf_db_trans_close (trans); + + json_t *response = json_object (); + if (ret != 0) { + json_object_set_new (response, "ok", json_false ()); + json_object_set_new (response, "reason", json_string ("lock_service_unavailable")); + } else if (conflict) { + json_object_set_new (response, "ok", json_false ()); + json_object_set_new (response, "reason", json_string ("locked")); + json_object_set_new (response, "owner", json_string (row.owner ? row.owner : "")); + json_object_set_new (response, "lease_until", json_integer (row.lease_until)); + } else { + json_object_set_new (response, "ok", json_true ()); + json_object_set_new (response, "lock_id", json_string (lock_id)); + json_object_set_new (response, "generation", json_string (generation)); + json_object_set_new (response, "lease_until", json_integer (now + lease_seconds)); + } + lock_row_clear (&row); + g_free (lock_id); g_free (generation); g_free (path_hash); g_free (path); json_decref (request); + return dump_response (response); +} + +char * +cf_lock_release_json (const char *request_json, GError **error) +{ + json_t *request = parse_request (request_json, error); + if (!request) + return NULL; + const char *repo_id, *owner = json_string (request, "owner"); + const char *generation = json_string (request, "generation"); + char *path = NULL; + if (!owner || !request_object (request, &repo_id, &path, error)) { + g_free (path); json_decref (request); + return bad_request (error, "owner, repo_id and path are required"); + } + gint64 now = (gint64)time (NULL); + CfLockRow current = {0}; + int current_ret = load_lock_row (repo_id, path, ¤t); + if (current_ret != 0 || !lock_is_live (¤t, now) || + !current.owner || strcmp (current.owner, owner) != 0 || + (generation && (!current.generation || strcmp (current.generation, generation) != 0))) { + json_t *response = json_object (); + json_object_set_new (response, "ok", json_false ()); + json_object_set_new (response, "reason", json_string ( + current_ret != 0 ? "lock_service_unavailable" : "not_owner_or_stale")); + lock_row_clear (¤t); + g_free (path); json_decref (request); + return dump_response (response); + } + lock_row_clear (¤t); + char *path_hash = g_compute_checksum_for_string (G_CHECKSUM_SHA1, path, -1); + const char *sql = generation + ? "UPDATE cf_lock_lease SET status='released', updated_at=? WHERE repo_id=? AND path_hash=? AND normalized_path=? AND owner=? AND generation=? AND status='active'" + : "UPDATE cf_lock_lease SET status='released', updated_at=? WHERE repo_id=? AND path_hash=? AND normalized_path=? AND owner=? AND status='active'"; + int n = generation ? 6 : 5; + int ret = generation + ? seaf_db_statement_query (seaf->db, sql, n, "int64", now, "string", repo_id, "string", path_hash, "string", path, "string", owner, "string", generation) + : seaf_db_statement_query (seaf->db, sql, n, "int64", now, "string", repo_id, "string", path_hash, "string", path, "string", owner); + json_t *response = json_object (); + json_object_set_new (response, "ok", json_boolean (ret == 0)); + if (ret == 0) + seaf_db_statement_query (seaf->db, + "INSERT INTO cf_lock_repo_revision (repo_id, revision, updated_at) VALUES (?, 1, ?) ON DUPLICATE KEY UPDATE revision=revision+1, updated_at=VALUES(updated_at)", + 2, "string", repo_id, "int64", now); + g_free (path_hash); g_free (path); json_decref (request); + return dump_response (response); +} diff --git a/common/cf-lock.h b/common/cf-lock.h new file mode 100644 index 00000000..feb11209 --- /dev/null +++ b/common/cf-lock.h @@ -0,0 +1,22 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +/* CloudFile's authoritative lease-based file lock backend. */ + +#ifndef CF_LOCK_H +#define CF_LOCK_H + +#include + +/* Registers the write-lifecycle provider when [cloudfile] file_lock_enabled + * is true and lock_backend is either unset or "cloudfile". */ +void cf_lock_init (void); + +/* JSON RPC adapters. A normal conflict is returned as {"ok":false,...}; a + * malformed request returns NULL and sets error. The caller owns the string. */ +char *cf_lock_status_json (const char *request_json, GError **error); +char *cf_lock_acquire_json (const char *request_json, GError **error); +char *cf_lock_release_json (const char *request_json, GError **error); + +gboolean cf_lock_enabled (void); + +#endif /* CF_LOCK_H */ diff --git a/common/rpc-service.c b/common/rpc-service.c index efc0229e..cba3c046 100644 --- a/common/rpc-service.c +++ b/common/rpc-service.c @@ -25,6 +25,7 @@ #include "cf-ext.h" #include "cf-fileop.h" #include "cf-fileop-json.h" +#include "cf-lock.h" #endif #ifndef SEAFILE_SERVER @@ -4244,6 +4245,45 @@ seafile_cf_fileop_aborted (const char *fop_json, GError **error) #endif } +/* CloudFile lease-lock control plane. These are intentionally separate from + * the legacy Pro RPC names: CE must not pretend that unrelated Pro features + * exist merely because it offers a compatible lock capability. */ +char * +seafile_cf_lock_status (const char *request_json, GError **error) +{ +#ifdef SEAFILE_SERVER + if (!cf_lock_enabled ()) + return g_strdup ("{\"ok\":false,\"reason\":\"disabled\"}"); + return cf_lock_status_json (request_json, error); +#else + return g_strdup ("{\"ok\":false,\"reason\":\"disabled\"}"); +#endif +} + +char * +seafile_cf_lock_acquire (const char *request_json, GError **error) +{ +#ifdef SEAFILE_SERVER + if (!cf_lock_enabled ()) + return g_strdup ("{\"ok\":false,\"reason\":\"disabled\"}"); + return cf_lock_acquire_json (request_json, error); +#else + return g_strdup ("{\"ok\":false,\"reason\":\"disabled\"}"); +#endif +} + +char * +seafile_cf_lock_release (const char *request_json, GError **error) +{ +#ifdef SEAFILE_SERVER + if (!cf_lock_enabled ()) + return g_strdup ("{\"ok\":false,\"reason\":\"disabled\"}"); + return cf_lock_release_json (request_json, error); +#else + return g_strdup ("{\"ok\":false,\"reason\":\"disabled\"}"); +#endif +} + GList * seafile_list_dir_with_perm (const char *repo_id, const char *path, diff --git a/include/seafile-rpc.h b/include/seafile-rpc.h index 679ec749..10444e43 100644 --- a/include/seafile-rpc.h +++ b/include/seafile-rpc.h @@ -994,6 +994,17 @@ seafile_cf_fileop_committed (const char *fop_json, GError **error); int seafile_cf_fileop_aborted (const char *fop_json, GError **error); +/* CloudFile's CE-specific lease-lock control plane. Request and response are + * JSON so optional session fields can evolve without widening a searpc ABI. */ +char * +seafile_cf_lock_status (const char *request_json, GError **error); + +char * +seafile_cf_lock_acquire (const char *request_json, GError **error); + +char * +seafile_cf_lock_release (const char *request_json, GError **error); + GList * seafile_list_dir_with_perm (const char *repo_id, const char *path, diff --git a/python/seafile/rpcclient.py b/python/seafile/rpcclient.py index 61fa0565..f71db7c5 100644 --- a/python/seafile/rpcclient.py +++ b/python/seafile/rpcclient.py @@ -534,6 +534,20 @@ def cf_fileop_committed(fop_json): def cf_fileop_aborted(fop_json): pass + # CloudFile lease locks. These deliberately do not reuse Pro's lock_file + # names, which are absent in CE and carry a broader feature contract. + @searpc_func("string", ["string"]) + def cf_lock_status(request_json): + pass + + @searpc_func("string", ["string"]) + def cf_lock_acquire(request_json): + pass + + @searpc_func("string", ["string"]) + def cf_lock_release(request_json): + pass + # org repo @searpc_func("string", ["string", "string", "string", "string", "string", "int", "int"]) def seafile_create_org_repo(name, desc, user, passwd, magic, random_key, enc_version, org_id): diff --git a/python/seaserv/api.py b/python/seaserv/api.py index 35a9d4f3..e9040b86 100644 --- a/python/seaserv/api.py +++ b/python/seaserv/api.py @@ -419,6 +419,15 @@ def check_file_lock(self, repo_id, path, user): """ return 0 + def cf_lock_status(self, request_json): + return seafserv_threaded_rpc.cf_lock_status(request_json) + + def cf_lock_acquire(self, request_json): + return seafserv_threaded_rpc.cf_lock_acquire(request_json) + + def cf_lock_release(self, request_json): + return seafserv_threaded_rpc.cf_lock_release(request_json) + # share repo to user def share_repo(self, repo_id, from_username, to_username, permission): return seafserv_threaded_rpc.add_share(repo_id, from_username, diff --git a/scripts/sql/mysql/cloudfile.sql b/scripts/sql/mysql/cloudfile.sql index c6afaf32..21760ce3 100644 --- a/scripts/sql/mysql/cloudfile.sql +++ b/scripts/sql/mysql/cloudfile.sql @@ -161,3 +161,42 @@ CREATE TABLE IF NOT EXISTS cf_search_index_state ( detail TEXT, UNIQUE INDEX cf_search_index_state_name (name) ) ENGINE=INNODB; + +-- File-lock truth for manual checkout, local editors and OnlyOffice. CE's +-- FileLocks table has no manager or write-path enforcement, so it is never +-- written at runtime. A lease is keyed by the normalized object path and a +-- fresh UUID generation is produced every time an expired/released row is +-- claimed; old sessions therefore cannot become valid again after a release. +CREATE TABLE IF NOT EXISTS cf_lock_lease ( + repo_id CHAR(36) NOT NULL, + normalized_path VARCHAR(1000) NOT NULL, + -- SHA1 is indexed instead of the full utf8mb4 path; all reads also compare + -- normalized_path so a theoretical digest collision cannot alias a lock. + path_hash CHAR(40) NOT NULL, + lock_id CHAR(36) NOT NULL, + generation CHAR(36) NOT NULL, + owner VARCHAR(255) NOT NULL, + kind VARCHAR(32) NOT NULL, + session_id CHAR(36), + device_id VARCHAR(255), + source_file_id CHAR(40), + source_commit_id CHAR(40), + lease_until BIGINT NOT NULL, + hard_expire_at BIGINT NOT NULL, + last_heartbeat_at BIGINT, + status VARCHAR(16) NOT NULL, + forced_by VARCHAR(255), + forced_reason TEXT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + PRIMARY KEY (repo_id, path_hash), + INDEX cf_lock_lease_live (repo_id, status, lease_until) +) ENGINE=INNODB; + +-- A monotonic, opaque value for clients that poll the lock set. Lease +-- refreshes do not update this value; acquire/release state transitions do. +CREATE TABLE IF NOT EXISTS cf_lock_repo_revision ( + repo_id CHAR(36) NOT NULL PRIMARY KEY, + revision BIGINT NOT NULL, + updated_at BIGINT NOT NULL +) ENGINE=INNODB; diff --git a/server/Makefile.am b/server/Makefile.am index 8c797fa5..130bcf75 100644 --- a/server/Makefile.am +++ b/server/Makefile.am @@ -41,6 +41,7 @@ noinst_HEADERS = web-accesstoken-mgr.h seafile-session.h \ ../common/cf-fileop.h \ ../common/cf-fileop-json.h \ ../common/cf-fileop-test.h \ + ../common/cf-lock.h \ ../common/cf-path.h \ ../common/cf-acl.h \ ../common/cf-acl-resolve.h \ @@ -98,6 +99,7 @@ seaf_server_SOURCES = \ ../common/cf-fileop.c \ ../common/cf-fileop-json.c \ ../common/cf-fileop-test.c \ + ../common/cf-lock.c \ ../common/cf-path.c \ ../common/cf-acl.c \ ../common/cf-acl-resolve.c \ diff --git a/server/seaf-server.c b/server/seaf-server.c index 4529ab9b..eec5fe98 100644 --- a/server/seaf-server.c +++ b/server/seaf-server.c @@ -696,6 +696,21 @@ static void start_rpc_service (const char *seafile_dir, seafile_cf_fileop_aborted, "cf_fileop_aborted", searpc_signature_int__string()); + + searpc_server_register_function ("seafserv-threaded-rpcserver", + seafile_cf_lock_status, + "cf_lock_status", + searpc_signature_string__string()); + + searpc_server_register_function ("seafserv-threaded-rpcserver", + seafile_cf_lock_acquire, + "cf_lock_acquire", + searpc_signature_string__string()); + + searpc_server_register_function ("seafserv-threaded-rpcserver", + seafile_cf_lock_release, + "cf_lock_release", + searpc_signature_string__string()); searpc_server_register_function ("seafserv-threaded-rpcserver", seafile_get_file_id_by_commit_and_path, From b8e86371e0ed49c5b83c64ec41e55ddc84d0fb94 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Thu, 6 Aug 2026 01:00:33 +0800 Subject: [PATCH 4/4] fix(file-lock): avoid Jansson helper collision --- common/cf-lock.c | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/common/cf-lock.c b/common/cf-lock.c index df4e756b..7050ea77 100644 --- a/common/cf-lock.c +++ b/common/cf-lock.c @@ -112,7 +112,7 @@ lock_is_live (const CfLockRow *row, gint64 now) } static const char * -json_string (json_t *obj, const char *name) +request_string (json_t *obj, const char *name) { json_t *value = json_object_get (obj, name); if (!value || !json_is_string (value)) @@ -168,8 +168,8 @@ parse_request (const char *request_json, GError **error) static gboolean request_object (json_t *request, const char **repo_id, char **path, GError **error) { - *repo_id = json_string (request, "repo_id"); - const char *raw_path = json_string (request, "path"); + *repo_id = request_string (request, "repo_id"); + const char *raw_path = request_string (request, "path"); if (!*repo_id || !raw_path) { bad_request (error, "repo_id and path are required"); return FALSE; @@ -306,8 +306,8 @@ cf_lock_acquire_json (const char *request_json, GError **error) json_t *request = parse_request (request_json, error); if (!request) return NULL; - const char *repo_id, *owner = json_string (request, "owner"); - const char *kind = json_string (request, "kind"); + const char *repo_id, *owner = request_string (request, "owner"); + const char *kind = request_string (request, "kind"); char *path = NULL; if (!owner || !kind || !request_object (request, &repo_id, &path, error)) { g_free (path); @@ -386,8 +386,8 @@ cf_lock_release_json (const char *request_json, GError **error) json_t *request = parse_request (request_json, error); if (!request) return NULL; - const char *repo_id, *owner = json_string (request, "owner"); - const char *generation = json_string (request, "generation"); + const char *repo_id, *owner = request_string (request, "owner"); + const char *generation = request_string (request, "generation"); char *path = NULL; if (!owner || !request_object (request, &repo_id, &path, error)) { g_free (path); json_decref (request);