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/AGENTS.md b/AGENTS.md index decfe51a..e67a53de 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,29 @@ 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-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()` 里没有注册任何能力,所以基线上每个钩子都是透传,行为与原生 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` 同一条理由。规格见 +`cloudfile-docker/docs/fileop-lifecycle.md`。 **为什么用注册表而不是直接调用某个能力:** @@ -106,7 +130,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-ext.c b/common/cf-ext.c index 1e2c6a0b..ca75acbb 100644 --- a/common/cf-ext.c +++ b/common/cf-ext.c @@ -8,6 +8,8 @@ #include "seafile-session.h" #include "cf-ext.h" #include "cf-acl.h" +#include "cf-fileop-test.h" +#include "cf-lock.h" typedef struct CfProvider { char *name; @@ -26,6 +28,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 +71,8 @@ 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 (); + cf_lock_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-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-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-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-lock.c b/common/cf-lock.c new file mode 100644 index 00000000..7050ea77 --- /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 * +request_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 = 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; + } + *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 = 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); + 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 = 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); + 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/cf-path.c b/common/cf-path.c new file mode 100644 index 00000000..7f4aff6a --- /dev/null +++ b/common/cf-path.c @@ -0,0 +1,74 @@ +/* -*- 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; +} + +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 new file mode 100644 index 00000000..1f2d3dfe --- /dev/null +++ b/common/cf-path.h @@ -0,0 +1,68 @@ +/* -*- 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); + +/* + * 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/common/rpc-service.c b/common/rpc-service.c index acffad03..cba3c046 100644 --- a/common/rpc-service.c +++ b/common/rpc-service.c @@ -23,6 +23,9 @@ * 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" +#include "cf-lock.h" #endif #ifndef SEAFILE_SERVER @@ -4120,6 +4123,167 @@ 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 +} + +/* 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/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..10444e43 100644 --- a/include/seafile-rpc.h +++ b/include/seafile-rpc.h @@ -968,6 +968,43 @@ 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); + +/* 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 81411efd..f71db7c5 100644 --- a/python/seafile/rpcclient.py +++ b/python/seafile/rpcclient.py @@ -511,6 +511,43 @@ 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 + + # 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 94edee06..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, @@ -698,6 +707,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/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 57e3d5d9..130bcf75 100644 --- a/server/Makefile.am +++ b/server/Makefile.am @@ -38,6 +38,11 @@ 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-fileop-test.h \ + ../common/cf-lock.h \ + ../common/cf-path.h \ ../common/cf-acl.h \ ../common/cf-acl-resolve.h \ ../common/cf-s3-client.h \ @@ -91,6 +96,11 @@ 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-fileop-test.c \ + ../common/cf-lock.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..85e48a2c 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); @@ -2056,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, @@ -2065,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; @@ -2112,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: @@ -2679,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; @@ -2815,6 +3048,8 @@ seaf_repo_manager_copy_file (SeafRepoManager *mgr, gboolean background = FALSE; 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); @@ -2827,22 +3062,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)) { @@ -2870,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, @@ -2919,7 +3172,27 @@ 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, + .commit_id = cf_commit_id); + 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 +3248,8 @@ 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; + char cf_commit_id[41] = ""; GET_REPO_OR_FAIL(src_repo, src_repo_id); @@ -3009,6 +3284,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)) { @@ -3071,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, @@ -3124,7 +3410,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, .commit_id = cf_commit_id); + 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); @@ -3165,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; @@ -3219,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: @@ -3397,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; @@ -3513,6 +3815,8 @@ 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; + char cf_commit_id[41] = ""; GET_REPO_OR_FAIL(src_repo, src_repo_id); @@ -3548,6 +3852,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 */ @@ -3607,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; } @@ -3621,6 +3939,7 @@ seaf_repo_manager_move_multiple_files (SeafRepoManager *mgr, user, FALSE, NULL, + cf_commit_id, NULL) < 0) { ret = -1; goto out; @@ -3678,12 +3997,27 @@ 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, + .commit_id = cf_commit_id); + 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 +4070,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 +4118,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 +4194,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 +4253,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 +4272,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 +4302,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 +4349,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 +4370,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 +4399,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 +4565,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 +4590,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 +4622,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 +4783,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 +4820,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 +4896,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 +4982,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 +5010,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 +5052,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 +5447,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 +5509,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 +5589,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 +5711,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 +5767,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 +5836,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 +6772,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 +6835,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..eec5fe98 100644 --- a/server/seaf-server.c +++ b/server/seaf-server.c @@ -675,6 +675,42 @@ 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_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, 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..5706ccd9 --- /dev/null +++ b/tests/cf-fileop/check-call-sites.py @@ -0,0 +1,249 @@ +#!/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 + + given = set() + 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 + 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;' + % (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..38046d33 --- /dev/null +++ b/tests/cf-fileop/gen-cases.py @@ -0,0 +1,124 @@ +#!/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)) + + # --- 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' + '} 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..c1724652 --- /dev/null +++ b/tests/cf-fileop/test-cf-fileop.c @@ -0,0 +1,397 @@ +/* -*- 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); + } +} + +/* --------------------------------------------------------- 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 +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_components (); + 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; +}