diff --git a/README.testing.md b/README.testing.md index 6f96822c..081d6e53 100644 --- a/README.testing.md +++ b/README.testing.md @@ -14,6 +14,8 @@ ```bash ./tests/cf-acl/run.sh ./tests/cf-fileop/run.sh +./tests/cf-permission-many/run.sh +./tests/cf-dir-share/run.sh # 目录分页另需 valac / libjansson 开发包 ./tests/cf-dir-page/run.sh @@ -33,6 +35,8 @@ go test -count=1 -run 'Cf[A-Z]' . | `tests/cf-acl/run.sh` | ACL 解析、继承、优先级、权限不放宽不变量 | 数据库查询、RPC 和进程集成 | | `tests/cf-dir-page/run.sh` | 生产 C 扫描、RPC、ACL 过滤及相邻 Hub 端点的共享分页用例 | 存储/数据库与进程间 RPC 使用 fixture,不等于部署 E2E | | `tests/cf-fileop/run.sh` | 路径/操作词汇、provider 分发、空 provider 透传、C 写入口字段与参数形状 | 运行时变量值是否传对、完整 seaf-server 编译 | +| `tests/cf-permission-many/run.sh` | 批量权限传输、标量/入口一致性、`check_permission_by_path` 在"无库权限 + 目录共享"下的回退与失败语义 | Hub 的 V2 裁决本身(用 stub 能力),不是部署 E2E | +| `tests/cf-dir-share/run.sh` | 目录共享资格的请求形状、路径规范化、r/rw 映射,以及拒绝/故障/缺字段的判别 | 真实 Hub HTTP、JWT 与进程间调用(channel 为 stub) | | Go `Cf*` 测试 | C/Go 词汇、JSON 字段、错误码、共享用例 | MySQL 依赖的上游测试 | | `tests/cf-s3/run.sh` | C commit/fs/block S3 backend 与错误语义 | 未设置端点时会跳过,不构成 S3 验收 | | `ci/run.py` | 上游构建、MySQL、C/Go fileserver 和 pytest | 需要 Linux 与完整依赖链 | diff --git a/common/cf-ext.c b/common/cf-ext.c index 65e50e88..4840825b 100644 --- a/common/cf-ext.c +++ b/common/cf-ext.c @@ -10,6 +10,7 @@ #include "cf-acl.h" #include "cf-fileop-test.h" #include "cf-lock.h" +#include "cf-v2-share.h" typedef struct CfProvider { char *name; @@ -18,7 +19,15 @@ typedef struct CfProvider { CfRestrictedFunc restricted; } CfProvider; +/* Independent qualifications, kept apart from the tightening chain on purpose; + * cf-ext.h explains why they cannot share it. */ +typedef struct CfShareProvider { + char *name; + CfShareGrantFunc grant; +} CfShareProvider; + static GList *providers = NULL; /* CfProvider*, registration order */ +static GList *share_providers = NULL; /* CfShareProvider*, registration order */ gboolean cf_ext_config_bool (const char *key) @@ -45,7 +54,7 @@ cf_ext_database (void) gboolean cf_ext_active (void) { - return providers != NULL; + return providers != NULL || share_providers != NULL; } void @@ -64,6 +73,20 @@ cf_ext_register (const char *name, seaf_message ("CloudFile: capability '%s' enabled.\n", name); } +void +cf_ext_register_share_grant (const char *name, CfShareGrantFunc grant) +{ + if (!grant) + return; + + CfShareProvider *p = g_new0 (CfShareProvider, 1); + p->name = g_strdup (name); + p->grant = grant; + + share_providers = g_list_append (share_providers, p); + seaf_message ("CloudFile: share qualification '%s' enabled.\n", name); +} + void cf_ext_init (void) { @@ -79,6 +102,33 @@ cf_ext_init (void) cf_acl_init (); cf_fileop_test_init (); cf_lock_init (); + cf_v2_share_init (); +} + +char * +cf_ext_share_grant (const char *repo_id, + const char *path, + const char *user, + gboolean *unavailable) +{ + if (unavailable) + *unavailable = FALSE; + if (!share_providers || !repo_id || !path || !user || !*user) + return NULL; + + GList *ptr; + for (ptr = share_providers; ptr; ptr = ptr->next) { + CfShareProvider *p = ptr->data; + char *grant = p->grant (repo_id, path, user, unavailable); + if (grant) + return grant; + /* Its own authoritative data was unreadable: no later capability can + * answer for it, and "denied" would misreport an outage. */ + if (unavailable && *unavailable) + return NULL; + } + + return NULL; } char * diff --git a/common/cf-ext.h b/common/cf-ext.h index eaaab3f4..721fd005 100644 --- a/common/cf-ext.h +++ b/common/cf-ext.h @@ -106,6 +106,49 @@ void cf_ext_register (const char *name, CfDirentFilterFunc dirent_filter, CfRestrictedFunc restricted); +/* ------------------------------------------- independent qualification */ + +/* + * A permission that exists *without* a native library grant. + * + * The chain above cannot express one, and must not be bent into doing so: it + * starts from @native_perm, every provider may only reduce it, and a NULL + * native permission ends it. A directory share is the opposite case -- it is + * the normal state of an account that is deliberately not a library member -- + * so a share root would have to start the chain from nothing and *widen*, + * which no provider in that chain is audited for. + * + * Hence this second, explicitly named seam. It is consulted only after + * seafile_check_permission() returned nothing for @user, and it answers with + * a permission of its own for that exact path. It may never return "admin" and + * may never reach a path the capability did not qualify; NULL means "no + * independent qualification", and callers keep failing closed. + * + * Not registered (a stock CE deployment, or every switch off) means the native + * denial stands, exactly as before this seam existed. + */ +typedef char * (*CfShareGrantFunc) (const char *repo_id, + const char *path, + const char *user, + gboolean *unavailable); + +/* Register an independent-qualification capability. */ +void cf_ext_register_share_grant (const char *name, CfShareGrantFunc grant); + +/* + * The first qualification any capability will vouch for, or NULL. + * + * @unavailable is optional and behaves as in cf_ext_check_permission: TRUE only + * when the capability that could have qualified the path could not read its own + * authoritative data, so the caller must answer "unavailable" rather than + * "denied". A capability that reports unavailable stops the search; a later + * one could not restore an answer the earlier one failed to compute. + */ +char *cf_ext_share_grant (const char *repo_id, + const char *path, + const char *user, + gboolean *unavailable); + /* ------------------------------------------------------------------ dispatch */ /* diff --git a/common/cf-v2-acl.c b/common/cf-v2-acl.c index e29287ae..2c965125 100644 --- a/common/cf-v2-acl.c +++ b/common/cf-v2-acl.c @@ -137,16 +137,29 @@ static size_t response_body(char *data, size_t size, size_t n, void *opaque) return length; } -static int authorize(struct _SeafileSession *session, json_t *request) +/* + * Transport only: one POST to the Hub's internal V2 check endpoint. + * + * Split out of authorize() so the directory-share qualification (cf-v2-share.c) + * reuses this channel instead of growing a second curl block with its own JWT + * lifetime rules -- two copies of an internal-auth handshake are one copy too + * many, and the one that drifts is always the less exercised one. + * + * Returns the HTTP status, or -1 when the channel itself is unusable: no key or + * url, JWT failure, transport error, oversized or truncated reply. @body is + * reset, and holds the reply on 200. + */ +long +cf_v2_acl_check_json(struct _SeafileSession *session, const char *payload, GString *body) { - int result=-1; + long status=-1; jwt_t *jwt=NULL; CURL *curl=NULL; struct curl_slist *headers=NULL; - char *token=NULL, *auth=NULL, *url=NULL, *payload=NULL; - GString *body=g_string_new(NULL); - json_t *reply=NULL; - if (!session->seahub_pk || !*session->seahub_pk || !session->seahub_url) goto done; + char *token=NULL, *auth=NULL, *url=NULL; + if (!session || !payload || !body) return -1; + g_string_truncate(body,0); + if (!session->seahub_pk || !*session->seahub_pk || !session->seahub_url) return -1; if (jwt_new(&jwt) || jwt_add_grant_bool(jwt,"is_internal",TRUE) || jwt_add_grant_int(jwt,"exp",time(NULL)+60) || jwt_set_alg(jwt,JWT_ALG_HS256,(unsigned char *)session->seahub_pk,strlen(session->seahub_pk))) goto done; @@ -158,8 +171,6 @@ static int authorize(struct _SeafileSession *session, json_t *request) auth=g_strdup_printf("Authorization: Token %s",token); headers=curl_slist_append(headers,auth); headers=curl_slist_append(headers,"Content-Type: application/json"); - payload=json_dumps(request,JSON_COMPACT); - if (!payload) goto done; curl_easy_setopt(curl,CURLOPT_URL,url); curl_easy_setopt(curl,CURLOPT_HTTPHEADER,headers); curl_easy_setopt(curl,CURLOPT_POSTFIELDS,payload); @@ -169,17 +180,30 @@ static int authorize(struct _SeafileSession *session, json_t *request) curl_easy_setopt(curl,CURLOPT_WRITEFUNCTION,response_body); curl_easy_setopt(curl,CURLOPT_WRITEDATA,body); if (curl_easy_perform(curl)!=CURLE_OK) goto done; - long status=0; - if (curl_easy_getinfo(curl,CURLINFO_RESPONSE_CODE,&status)!=CURLE_OK || status!=200) goto done; - reply=json_loadb(body->str,body->len,JSON_REJECT_DUPLICATES,NULL); - if (json_is_true(json_object_get(reply,"allowed")) && - !g_strcmp0(json_string_value(json_object_get(reply,"contract")),"cf-acl-v1")) result=0; + if (curl_easy_getinfo(curl,CURLINFO_RESPONSE_CODE,&status)!=CURLE_OK) status=-1; done: if (curl) curl_easy_cleanup(curl); curl_slist_free_all(headers); if (jwt) jwt_free(jwt); + g_free(token);g_free(auth);g_free(url); + return status; +} + +static int authorize(struct _SeafileSession *session, json_t *request) +{ + int result=-1; + char *payload=NULL; + GString *body=g_string_new(NULL); + json_t *reply=NULL; + payload=json_dumps(request,JSON_COMPACT); + if (!payload) goto done; + if (cf_v2_acl_check_json(session,payload,body)!=200) goto done; + reply=json_loadb(body->str,body->len,JSON_REJECT_DUPLICATES,NULL); + if (json_is_true(json_object_get(reply,"allowed")) && + !g_strcmp0(json_string_value(json_object_get(reply,"contract")),"cf-acl-v1")) result=0; +done: json_decref(reply); - g_free(token);g_free(auth);g_free(url);g_free(payload);g_string_free(body,TRUE); + g_free(payload);g_string_free(body,TRUE); return result; } diff --git a/common/cf-v2-acl.h b/common/cf-v2-acl.h index 28f29e53..c01f08df 100644 --- a/common/cf-v2-acl.h +++ b/common/cf-v2-acl.h @@ -18,4 +18,10 @@ int cf_v2_acl_final(SeafDBTrans *trans, struct _SeafileSession *session, const char *repo_id); int cf_v2_acl_final_scoped(SeafDBTrans *trans, struct _SeafileSession *session, const char *repo_id, const char *old_id, const char *new_id, const char *actor); +/* Transport only: one internal-JWT POST to /cloudfile/acl/check/. Returns the + * HTTP status, or -1 when the channel is unusable. @body is reset and holds the + * reply on 200. The single implementation of that handshake; cf-v2-share.c + * consumes it rather than keeping a second copy. */ +long cf_v2_acl_check_json(struct _SeafileSession *session, const char *payload, + GString *body); #endif diff --git a/common/cf-v2-share.c b/common/cf-v2-share.c new file mode 100644 index 00000000..1c01166e --- /dev/null +++ b/common/cf-v2-share.c @@ -0,0 +1,182 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +#include "common.h" + +#include + +#include + +#include "log.h" +#include "seafile-session.h" +#include "cf-ext.h" +#include "cf-v2-acl.h" +#include "cf-v2-share.h" +#include "cloudfile-policy.h" + +/* One path, one check: the request is a fixed shape, so the bound only has to + * catch a path that already escaped the RPC's own validation. */ +#define CF_SHARE_PAYLOAD_MAX 8192 +#define CF_SHARE_PATH_MAX 4096 +#define CF_SHARE_USER_MAX 255 + +/* + * Strip the one spelling difference the Hub will not accept. + * + * callers hand this seam whatever the upstream RPC received, and several of + * them carry a directory path with a trailing slash. The V2 canonical form has + * none, and the endpoint answers 400 for it -- a valid share root would then + * read as "no qualification" for some callers and work for others, which is + * exactly the kind of per-entry-point difference this seam exists to remove. + * Everything else (dot segments, empty segments, backslashes, control bytes) is + * left for the Hub to reject, so there is still one canonicalizer. + */ +static char * +request_path (const char *path) +{ + if (!path || path[0] != '/' || strlen (path) > CF_SHARE_PATH_MAX) + return NULL; + + char *normalized = g_strdup (path); + size_t length = strlen (normalized); + while (length > 1 && normalized[length - 1] == '/') + normalized[--length] = '\0'; + + /* The library root is never a share root: a share is strictly below it, and + * asking would only spend a round trip to be told "invisible". */ + if (length == 1) { + g_free (normalized); + return NULL; + } + return normalized; +} + +static char * +qualified_permission (const char *repo_id, const char *path, const char *user, + gboolean *unavailable) +{ + json_t *request = json_object (); + json_t *checks = json_array (); + json_t *check = json_pack ("{s:s,s:s,s:s}", "path", path, "kind", "dir", + "action", "read"); + json_t *username = json_string (user); + json_t *repo = json_string (repo_id); + json_t *reply = NULL, *permissions = NULL; + GString *body = g_string_new (NULL); + char *payload = NULL; + char *result = NULL; + + /* Non-stealing setters throughout, so every local has exactly one owner and + * the single cleanup below is the whole story. */ + if (!request || !checks || !check || !username || !repo || + json_array_append (checks, check) || + json_object_set (request, "checks", checks) || + json_object_set (request, "username", username) || + json_object_set (request, "repo_id", repo)) + goto done; + + payload = json_dumps (request, JSON_COMPACT); + if (!payload || strlen (payload) > CF_SHARE_PAYLOAD_MAX) + goto done; + + long status = cf_v2_acl_check_json (seaf, payload, body); + if (status != 200) { + /* 5xx and transport failures are outages, not answers, and must not be + * reported to the user as "no permission" (the Hub turns this into 503 + * with a distinguishable code). 4xx is the authority saying no: 403 for + * a path outside every qualified root, 400 for a path it rejects, 404 + * for an account with no business subject. */ + if (status < 0 || status >= 500) { + if (unavailable) *unavailable = TRUE; + } + goto done; + } + + reply = json_loadb (body->str, body->len, JSON_REJECT_DUPLICATES, NULL); + if (!json_is_true (json_object_get (reply, "allowed")) || + g_strcmp0 (json_string_value (json_object_get (reply, "contract")), + "cf-acl-v1")) + goto done; + + /* The level, not just the verdict: an r-qualified root must not become + * uploadable. A Hub without this field is older than this seam, and the + * only safe reading is then "cannot qualify" -- never "assume rw". */ + permissions = json_object_get (reply, "permissions"); + if (!json_is_array (permissions) || json_array_size (permissions) != 1) + goto done; + const char *level = json_string_value (json_array_get (permissions, 0)); + if (!g_strcmp0 (level, "rw") || !g_strcmp0 (level, "r")) + result = g_strdup (level); + +done: + json_decref (request); + json_decref (checks); + json_decref (check); + json_decref (username); + json_decref (repo); + json_decref (reply); + g_free (payload); + g_string_free (body, TRUE); + return result; +} + +char * +cf_v2_share_grant (const char *repo_id, const char *path, const char *user, + gboolean *unavailable) +{ + if (unavailable) *unavailable = FALSE; + if (!seaf || !repo_id || strlen (repo_id) != 36 || + !user || !*user || strlen (user) > CF_SHARE_USER_MAX) + return NULL; + + char *normalized = request_path (path); + if (!normalized) + return NULL; + + char *permission = qualified_permission (repo_id, normalized, user, unavailable); + g_free (normalized); + return permission; +} + +/* Standard V2 uses only the three reviewed extension tables, not the + * retired cf_managed_library/cf_schema_migration schema. This startup check + * detects the installed V2 contract; Hub remains authoritative for all + * per-user/path grants and rejects malformed schema or ACL data. */ +static gboolean +v2_share_schema_present (void) +{ + if (!seaf || !seaf->db || seaf_db_type (seaf->db) != SEAF_DB_TYPE_MYSQL) + return FALSE; + + gboolean error = FALSE; + gboolean installed = seaf_db_statement_exists ( + seaf->db, + "SELECT 1 FROM information_schema.tables " + "WHERE table_schema=DATABASE() AND table_name IN " + "('cf_library_storage','cf_dir_acl','cf_sso_group_map') " + "GROUP BY table_schema HAVING COUNT(DISTINCT table_name)=3", + &error, 0); + if (error) + seaf_warning ("CloudFile: V2 directory-share schema lookup failed.\n"); + return installed && !error; +} + +void +cf_v2_share_init (void) +{ + /* Auto-detect standard V2 and reuse the authenticated internal Hub + * channel. Stock CE and incomplete V2 installs retain native denial. */ + if (!v2_share_schema_present ()) + return; + + if (!seaf->seahub_url || !*seaf->seahub_url || + !seaf->seahub_pk || !*seaf->seahub_pk) { + /* Without the internal channel a share-only account can only be + * refused. Say so once at startup instead of failing silently per + * request, where it would look like a missing share. */ + seaf_warning ("CloudFile: no internal Hub channel; directory shares " + "cannot qualify native writes.\n"); + return; + } + + cf_ext_register_share_grant ("v2-dir-share", cf_v2_share_grant); +} diff --git a/common/cf-v2-share.h b/common/cf-v2-share.h new file mode 100644 index 00000000..529a51a2 --- /dev/null +++ b/common/cf-v2-share.h @@ -0,0 +1,33 @@ +/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ + +#ifndef CF_V2_SHARE_H +#define CF_V2_SHARE_H + +#include + +/* + * Independent directory-share qualification for the native permission RPCs. + * + * A CloudFile V2 directory share exists for accounts with no library + * membership at all. Native Seafile has no such concept: repo-perm.c only knows + * owner, user share, group share and public, so the library gate answers "no + * permission" and every write entry (upload link, mkdir, rename, delete, + * seafdav) stops there. Reads worked because they were re-adjudicated above the + * gate; writes were not, which is why a share-only account could browse and + * download but never upload. + * + * This capability answers that one question and nothing else: for an exact path + * under an already-published share root, it reports the V2 permission the Hub + * authority computes. It cannot grant anything outside a qualified root, it + * never returns "admin", and it is consulted only after the native gate denied. + */ + +/* Auto-register for the three-table V2 schema and an internal Hub channel. + * No legacy managed-library schema or additional feature switch is required. */ +void cf_v2_share_init (void); + +/* "r" or "rw" for a share-qualified path, else NULL. Never "admin". */ +char *cf_v2_share_grant (const char *repo_id, const char *path, const char *user, + gboolean *unavailable); + +#endif /* CF_V2_SHARE_H */ diff --git a/common/rpc-service.c b/common/rpc-service.c index 61ace826..5727a8fe 100644 --- a/common/rpc-service.c +++ b/common/rpc-service.c @@ -4646,8 +4646,36 @@ seafile_check_permission_by_path (const char *repo_id, const char *path, */ char *perm = seafile_check_permission (repo_id, user, error); #ifdef SEAFILE_SERVER - if (!perm) + if (!perm) { + /* + * No native grant. That is not automatically the end: a CloudFile + * directory share is deliberately given to accounts that are not + * library members, so the only qualification that can exist here comes + * from a capability, not from repo-perm.c. Reads already worked because + * they are re-adjudicated above this gate; upload links, mkdir, rename, + * delete and seafdav had nowhere to go and were refused outright. + * + * Two things keep this from becoming a hole. An error means the gate + * failed, not that it denied, so it is propagated instead of being + * retried as a share question. And the answer is scoped: the capability + * returns a permission for this exact path only, inside a share root + * that policy already published, and can never answer "admin" -- the + * library root and every unshared path stay denied, and the C/Go final + * publication checks are untouched. + */ + if (error && *error) + return NULL; + + gboolean unavailable = FALSE; + char *grant = cf_ext_share_grant (repo_id, path, user, &unavailable); + if (grant) + return grant; + + if (unavailable) + g_set_error (error, SEAFILE_DOMAIN, SEAF_ERR_GENERAL, + "CloudFile permission runtime is unavailable"); return NULL; + } gboolean unavailable = FALSE; char *narrowed = cf_ext_check_permission (repo_id, path, user, perm, &unavailable); diff --git a/server/Makefile.am b/server/Makefile.am index bb5af940..be83256a 100644 --- a/server/Makefile.am +++ b/server/Makefile.am @@ -55,6 +55,7 @@ noinst_HEADERS = web-accesstoken-mgr.h seafile-session.h \ ../common/cf-auto-storage.h \ ../common/cf-acl.h \ ../common/cf-acl-resolve.h \ + ../common/cf-v2-share.h \ ../common/cf-s3-client.h \ ../common/storage-backend-multi.h \ index-blocks-mgr.h \ @@ -109,6 +110,7 @@ seaf_server_SOURCES = \ ../common/group-mgr.c \ ../common/org-mgr.c \ ../common/cf-v2-acl.c \ + ../common/cf-v2-share.c \ ../common/cf-v2-storage.c \ ../common/cf-ext.c \ ../common/cf-dir-page.c \ diff --git a/tests/cf-dir-share/compile-fixture.py b/tests/cf-dir-share/compile-fixture.py new file mode 100644 index 00000000..9f08ea6d --- /dev/null +++ b/tests/cf-dir-share/compile-fixture.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Compile the real share-qualification code against a fake Hub channel. + +Only the transport is faked. The path canonicalization, the request shape, the +verdict parsing and the r-vs-rw mapping are the shipped code, because those are +exactly the parts a mismatch with the Hub endpoint would silently break: the +wrong JSON key or an ignored `permissions` field would read as "no +qualification" and send a share-only user back to a 403. +""" +from pathlib import Path +import re +import subprocess +import sys + +root = Path(__file__).resolve().parents[2] +build = Path(sys.argv[1]).resolve() +build.mkdir(parents=True, exist_ok=True) +source = (root / 'common/cf-v2-share.c').read_text() + + +def function(name): + start = re.search(r'(?m)^(?:static )?(?:char \*|gboolean|void)\n' + re.escape(name) + r'\s*\(', source) + if start is None: + raise ValueError(name) + body = source.index('{', start.start()) + tokens = re.compile(r'/\*.*?\*/|//[^\n]*|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|[{}]', re.S) + depth = 0 + for token in tokens.finditer(source, body): + if token.group() == '{': + depth += 1 + elif token.group() == '}': + depth -= 1 + if not depth: + return source[start.start():token.end()] + raise ValueError(name) + + +# The limits stay single-sourced: copying a magic number into the fixture would +# let the test pass while the shipped bound drifted. +limits = '\n'.join(re.findall(r'(?m)^#define CF_SHARE_\w+ .*$', source)) +functions = '\n\n'.join(function(name) for name in + ('request_path', 'qualified_permission', 'cf_v2_share_grant', + 'v2_share_schema_present', 'cf_v2_share_init')) +(build / 'production.c').write_text(limits + '\n\n' + functions) + +cflags = subprocess.check_output(['pkg-config', '--cflags', 'glib-2.0', 'jansson'], text=True).split() +ldflags = subprocess.check_output(['pkg-config', '--libs', 'glib-2.0', 'jansson'], text=True).split() +base = ['cc', '-std=c99', '-Wall', '-Wextra', '-Werror', '-Wno-unused-parameter', '-fPIC', + '-I' + str(root / 'common'), '-I' + str(build)] +subprocess.run(base + ['-shared', str(root / 'tests/cf-dir-share/fixture.c'), + '-o', str(build / 'share.so'), *cflags, *ldflags], check=True) diff --git a/tests/cf-dir-share/fixture.c b/tests/cf-dir-share/fixture.c new file mode 100644 index 00000000..c6166c9b --- /dev/null +++ b/tests/cf-dir-share/fixture.c @@ -0,0 +1,99 @@ +/* Real share-qualification code; only the Hub channel is fake. */ +#include +#include +#include + +/* Exercise the extracted production registration path as well as the grant: + * only database/schema detection and the internal HTTP channel are stubbed. */ +typedef struct SeafDB { int unused; } SeafDB; +#define SEAF_DB_TYPE_MYSQL 1 +struct _SeafileSession { + SeafDB *db; + const char *seahub_url; + const char *seahub_pk; +}; +static SeafDB fake_db; +static struct _SeafileSession session = { &fake_db, "https://hub.test", "internal-jwt" }; +static struct _SeafileSession *seaf = &session; +static int schema_present, schema_fault, db_kind = SEAF_DB_TYPE_MYSQL, registrations; + +static int seaf_db_type(SeafDB *db) { (void)db; return db_kind; } +static gboolean seaf_db_statement_exists(SeafDB *db, const char *sql, gboolean *error, int n) +{ + (void)db; (void)n; + /* The current V2 contract has exactly these three extension tables; + * a legacy-only installation must never make the new grant active. */ + if (!strstr(sql, "SELECT 1 FROM information_schema.tables") || + !strstr(sql, "'cf_library_storage'") || !strstr(sql, "'cf_dir_acl'") || + !strstr(sql, "'cf_sso_group_map'") || strstr(sql, "cf_managed_library") || + !strstr(sql, "COUNT(DISTINCT table_name)=3")) + return FALSE; + if (schema_fault) { *error = TRUE; return FALSE; } + return schema_present != 0; +} +static void seaf_warning(const char *format, ...) { (void)format; } +char *cf_v2_share_grant(const char *, const char *, const char *, gboolean *); +static void cf_ext_register_share_grant(const char *name, + char *(*grant)(const char *, const char *, const char *, gboolean *)) +{ + if (strcmp(name, "v2-dir-share") == 0 && grant == cf_v2_share_grant) + ++registrations; +} + +static int channel_status; +static char *channel_body; +static char *last_payload; +static int calls; + +long cf_v2_acl_check_json(struct _SeafileSession *ignored, const char *payload, GString *body); + +/* Stands in for cf-v2-acl.c's transport. It never reaches the network: the + * status and body each test wants are exactly what a Hub reply or an outage + * would hand back. */ +long cf_v2_acl_check_json(struct _SeafileSession *ignored, const char *payload, GString *body) +{ + (void)ignored; + ++calls; + g_free(last_payload); + last_payload = g_strdup(payload); + if (channel_status == 200 && channel_body) + g_string_append(body, channel_body); + return channel_status; +} + +#include "production.c" + +void fixture_reset(void) +{ + channel_status = 403; + g_clear_pointer(&channel_body, g_free); + channel_body = g_strdup("{\"code\":\"ACL_DENIED\"}"); + g_clear_pointer(&last_payload, g_free); + calls = 0; + schema_present = schema_fault = registrations = 0; + db_kind = SEAF_DB_TYPE_MYSQL; + session.db = &fake_db; + session.seahub_url = "https://hub.test"; + session.seahub_pk = "internal-jwt"; +} +void fixture_registration_context(int present, int kind, int channel, int fault) +{ + schema_present = present; + db_kind = kind; + schema_fault = fault; + registrations = 0; + session.seahub_url = channel ? "https://hub.test" : NULL; + session.seahub_pk = channel ? "internal-jwt" : NULL; +} +int fixture_registrations(void) { return registrations; } + +void fixture_reply(int status, const char *body) +{ + channel_status = status; + g_clear_pointer(&channel_body, g_free); + channel_body = body ? g_strdup(body) : NULL; +} + +int fixture_calls(void) { return calls; } +const char *fixture_payload(void) { return last_payload; } +void fixture_free(void *value) { g_free(value); } diff --git a/tests/cf-dir-share/run.sh b/tests/cf-dir-share/run.sh new file mode 100755 index 00000000..9f74be9f --- /dev/null +++ b/tests/cf-dir-share/run.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Compile the real share-qualification code with a fake Hub channel; no network. +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +build=$(mktemp -d) +trap 'rm -rf "$build"' EXIT +python3 "$here/compile-fixture.py" "$build" +CF_DIR_SHARE_LIBRARY="$build/share.so" python3 "$here/test-share-native.py" -v diff --git a/tests/cf-dir-share/test-share-native.py b/tests/cf-dir-share/test-share-native.py new file mode 100644 index 00000000..e976300a --- /dev/null +++ b/tests/cf-dir-share/test-share-native.py @@ -0,0 +1,156 @@ +"""What the native side asks the Hub when an account has no library grant. + +The seam itself (who may call it, and only after a denial) is proven in +tests/cf-permission-many; this file proves the exchange: the request names the +exact path the caller asked about, and only an explicit r/rw level is ever +turned into a permission. Everything else stays "no qualification", because a +wrong guess here hands a share-only account a write it was never granted. +""" +import ctypes +import json +import os +import unittest + +lib = ctypes.CDLL(os.environ['CF_DIR_SHARE_LIBRARY']) +lib.cf_v2_share_grant.argtypes = [ctypes.c_char_p] * 3 + [ctypes.POINTER(ctypes.c_int)] +lib.cf_v2_share_grant.restype = ctypes.c_void_p +lib.fixture_reply.argtypes = [ctypes.c_int, ctypes.c_char_p] +lib.fixture_payload.restype = ctypes.c_char_p +lib.fixture_free.argtypes = [ctypes.c_void_p] +lib.fixture_registration_context.argtypes = [ctypes.c_int] * 4 +lib.fixture_registrations.restype = ctypes.c_int +lib.cf_v2_share_init.argtypes = [] +lib.cf_v2_share_init.restype = None +REPO = '11111111-1111-4111-8111-111111111111' +ALLOW = json.dumps(dict(allowed=True, contract='cf-acl-v1', permissions=['rw'])) + + +def grant(path='/shared/team', user='shared-user@etech.local', repo=REPO): + unavailable = ctypes.c_int(0) + pointer = lib.cf_v2_share_grant(repo.encode(), path.encode(), user.encode(), + ctypes.byref(unavailable)) + value = None + if pointer: + try: + value = ctypes.string_at(pointer).decode() + finally: + lib.fixture_free(pointer) + return value, bool(unavailable.value) + + +def reply(status, body): + lib.fixture_reply(status, body.encode() if body is not None else None) + + +class DirectoryShareTests(unittest.TestCase): + def setUp(self): + lib.fixture_reset() + + def reply(self, permissions=None, status=200, **extra): + body = dict(allowed=True, contract='cf-acl-v1', **extra) + if permissions is not None: + body['permissions'] = permissions + reply(status, json.dumps(body)) + + def test_qualified_root_returns_the_published_level(self): + self.reply(['rw']) + self.assertEqual(grant(), ('rw', False)) + self.reply(['r']) + self.assertEqual(grant(), ('r', False)) + + def test_request_names_the_exact_path_and_user(self): + # The Hub decides per path. A single wrong field would make the answer + # about a different directory, which is the whole failure mode this + # seam replaces. + self.reply(['rw']) + grant('/shared/team/design') + self.assertEqual(json.loads(lib.fixture_payload()), dict( + username='shared-user@etech.local', repo_id=REPO, + checks=[dict(path='/shared/team/design', kind='dir', action='read')])) + + def test_a_trailing_slash_is_the_same_directory(self): + # Callers hand over whatever the upstream RPC received; the Hub rejects + # the trailing-slash spelling with 400, which would qualify a path for + # some entry points and not others. + self.reply(['rw']) + self.assertEqual(grant('/shared/team/'), ('rw', False)) + self.assertEqual(json.loads(lib.fixture_payload())['checks'][0]['path'], + '/shared/team') + + def test_the_library_root_is_never_a_share_root(self): + self.reply(['rw']) + self.assertEqual(grant('/'), (None, False)) + self.assertEqual(lib.fixture_calls(), 0) + + def test_a_relative_path_never_reaches_the_authority(self): + self.reply(['rw']) + for path in ('', 'shared/team'): + with self.subTest(path=path): + self.assertEqual(grant(path), (None, False)) + self.assertEqual(lib.fixture_calls(), 0) + # Dot segments are still the Hub's to reject: a second canonicalizer + # here would be a second source of truth for what a path means. The + # fake authority accepts everything, so this asserts what was asked, + # not what came back. + grant('/shared/../etc') + self.assertEqual(json.loads(lib.fixture_payload())['checks'][0]['path'], + '/shared/../etc') + + def test_denial_is_not_an_outage(self): + reply(403, json.dumps(dict(code='ACL_DENIED'))) + self.assertEqual(grant(), (None, False)) + + def test_outage_is_reported_as_unavailable(self): + for status in (500, 502, 503, -1): + with self.subTest(status=status): + lib.fixture_reset() + reply(status, '{"code":"ACL_UNAVAILABLE"}') + self.assertEqual(grant(), (None, True)) + + def test_a_verdict_without_a_level_is_not_a_grant(self): + # An older Hub answers allowed/contract only. Assuming rw would hand out + # a write; assuming r would break uploads with a misleading 403. The + # only safe reading is "this authority cannot qualify". + for extra in ({}, dict(permissions=[]), dict(permissions=['rw', 'r']), + dict(permissions=[None]), dict(permissions=['admin']), + dict(permissions=['invisible']), dict(permissions='rw')): + with self.subTest(extra=extra): + self.reply(**extra) + self.assertEqual(grant(), (None, False)) + + def test_contract_and_verdict_are_both_required(self): + for body in (dict(allowed=False, contract='cf-acl-v1', permissions=['rw']), + dict(allowed=True, contract='v1', permissions=['rw']), + dict(allowed='true', contract='cf-acl-v1', permissions=['rw'])): + with self.subTest(body=body): + reply(200, json.dumps(body)) + self.assertEqual(grant(), (None, False)) + reply(200, 'not json') + self.assertEqual(grant(), (None, False)) + + +class RegistrationTests(unittest.TestCase): + """The three-table V2 install must register without retired tables or flags.""" + + def setUp(self): + lib.fixture_reset() + + def registration(self, *, schema, db_kind=1, channel=1, fault=0): + lib.fixture_registration_context(schema, db_kind, channel, fault) + lib.cf_v2_share_init() + return lib.fixture_registrations() + + def test_standard_v2_three_tables_register_the_share_grant(self): + self.assertEqual(self.registration(schema=1), 1) + + def test_missing_schema_or_non_mysql_keeps_ce_denial(self): + self.assertEqual(self.registration(schema=0), 0) + self.assertEqual(self.registration(schema=1, db_kind=2), 0) + + def test_unreadable_schema_or_missing_internal_channel_never_registers(self): + self.assertEqual(self.registration(schema=1, fault=1), 0) + self.assertEqual(self.registration(schema=1, channel=0), 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/cf-permission-many/compile-fixture.py b/tests/cf-permission-many/compile-fixture.py index 5d851f6a..49eda5df 100644 --- a/tests/cf-permission-many/compile-fixture.py +++ b/tests/cf-permission-many/compile-fixture.py @@ -28,6 +28,7 @@ def function(relative, name): raise ValueError(name) names = [('common/cf-acl.c', 'cf_acl_apply'), ('common/cf-ext.c', 'cf_ext_check_permission'), + ('common/cf-ext.c', 'cf_ext_share_grant'), ('common/rpc-service.c', 'seafile_check_permission'), ('common/rpc-service.c', 'seafile_check_permission_by_path'), ('common/rpc-service.c', 'seafile_cf_check_permissions_many')] diff --git a/tests/cf-permission-many/fixture.c b/tests/cf-permission-many/fixture.c index 850945af..fb9346be 100644 --- a/tests/cf-permission-many/fixture.c +++ b/tests/cf-permission-many/fixture.c @@ -9,7 +9,7 @@ static struct { void *repo_mgr; } session; static __typeof__(session) *seaf = &session; static gboolean cf_acl_on = TRUE; -static int counters[3]; /* qualification, managed-boundary check, provider hook */ +static int counters[4]; /* qualification, managed-boundary check, hook, share */ static int fail_at, timeout_at, deny_repo, managed, boundary_unavailable; static gint64 now; gint64 fixture_clock(void) { return now; } @@ -49,19 +49,42 @@ static char *cf_acl_apply(const char *, const char *, const char *, const char * typedef struct { char *(*perm)(const char *, const char *, const char *, const char *, gboolean *); } CfProvider; static CfProvider acl = {cf_acl_apply}, hook = {provider_hook}; static GList *providers; + +/* Independent qualification (cf-ext.h). The real dispatcher runs over one stub + * capability, so the tests exercise the seam the server calls instead of a + * reimplementation of it. counters[3] counts capability invocations: "was the + * share question even asked" is the assertion that matters most. */ +static char *share_level; +static int share_unavailable; +static char *share_capability(const char *repo, const char *path, const char *user, + gboolean *unavailable) +{ + (void)repo; (void)path; (void)user; + ++counters[3]; + if (share_unavailable) { if (unavailable) *unavailable = TRUE; return NULL; } + return share_level ? g_strdup(share_level) : NULL; +} +typedef struct { char *name; char *(*grant)(const char *, const char *, const char *, gboolean *); } CfShareProvider; +static CfShareProvider share = {"stub-share", share_capability}; +static GList *share_providers; #include "production.c" void fixture_reset(void) { memset(counters, 0, sizeof(counters)); now = 0; fail_at = timeout_at = deny_repo = managed = boundary_unavailable = 0; + g_clear_pointer(&share_level, g_free); + share_unavailable = 0; if (!providers) { providers = g_list_append(providers, &acl); providers = g_list_append(providers, &hook); } + if (!share_providers) share_providers = g_list_append(share_providers, &share); } void fixture_fail_at(int at) { fail_at = at; } void fixture_timeout_at(int at) { timeout_at = at; } void fixture_deny_repo(int value) { deny_repo = value; } void fixture_manage(int value) { managed = value; } void fixture_boundary_unavailable(int value) { boundary_unavailable = value; } +void fixture_share_level(const char *level) { g_free(share_level); share_level = level ? g_strdup(level) : NULL; } +void fixture_share_unavailable(int value) { share_unavailable = value; } int fixture_count(int index) { return counters[index]; } static char *finish(char *result, GError *error) { diff --git a/tests/cf-permission-many/run.sh b/tests/cf-permission-many/run.sh index 1e8e6409..c0c83597 100644 --- a/tests/cf-permission-many/run.sh +++ b/tests/cf-permission-many/run.sh @@ -6,3 +6,6 @@ build=$(mktemp -d) trap 'rm -rf "$build"' EXIT python3 "$here/compile-fixture.py" "$build" CF_PERMISSION_MANY_LIBRARY="$build/many.so" python3 "$here/test-many.py" -v +# Same compiled object: the share qualification is one more branch of the same +# scalar decision, so it is proven against the same code rather than a copy. +CF_PERMISSION_MANY_LIBRARY="$build/many.so" python3 "$here/test-share-grant.py" -v diff --git a/tests/cf-permission-many/test-share-grant.py b/tests/cf-permission-many/test-share-grant.py new file mode 100644 index 00000000..ae8fe0f9 --- /dev/null +++ b/tests/cf-permission-many/test-share-grant.py @@ -0,0 +1,89 @@ +"""Native permission for an account that holds only a directory share. + +The reported P0 (2026-10-20) was that a user with no library permission who was +granted rw on a shared directory could browse and download but never upload, +mkdir or rename: EAP allowed the write, then the native upload link / mkdir / +rename / delete gate asked seafile_check_permission_by_path, got nothing from +repo-perm.c, and stopped. These cases pin the native half of the fix -- the +qualification is consulted, and it is consulted *only* when the library gate +denied rather than failed. +""" +import ctypes +import json +import os +import unittest + +lib = ctypes.CDLL(os.environ['CF_PERMISSION_MANY_LIBRARY']) +lib.fixture_scalar.argtypes = [ctypes.c_char_p] * 3 +lib.fixture_scalar.restype = ctypes.c_void_p +lib.fixture_share_level.argtypes = [ctypes.c_char_p] +lib.fixture_free.argtypes = [ctypes.c_void_p] +REPO = '11111111-1111-4111-8111-111111111111' + + +def scalar(path='/shared/team'): + pointer = lib.fixture_scalar(REPO.encode(), path.encode(), b'alice') + if not pointer: + return None + try: + return ctypes.string_at(pointer).decode() + finally: + lib.fixture_free(pointer) + + +class ShareQualificationTests(unittest.TestCase): + def setUp(self): + lib.fixture_reset() + + def test_share_root_replaces_a_missing_library_grant(self): + lib.fixture_deny_repo(1) + lib.fixture_share_level(b'rw') + self.assertEqual(scalar(), 'rw') + # The tightening chain starts from a native grant; there is none, so it + # must not have been entered at all (index 2 is its hook). + self.assertEqual(lib.fixture_count(2), 0) + self.assertEqual(lib.fixture_count(3), 1) + + def test_read_only_share_never_becomes_a_write(self): + lib.fixture_deny_repo(1) + lib.fixture_share_level(b'r') + self.assertEqual(scalar(), 'r') + + def test_unqualified_path_still_denies_without_an_error(self): + # The normal case: no grant anywhere. A denial stays a denial, so the + # Hub keeps answering 403 instead of inventing a 503. + lib.fixture_deny_repo(1) + self.assertIsNone(scalar()) + self.assertEqual(lib.fixture_count(3), 1) + + def test_unreadable_qualification_is_an_outage_not_a_denial(self): + lib.fixture_deny_repo(1) + lib.fixture_share_unavailable(1) + self.assertEqual(json.loads(scalar()), {'rpc_error': True}) + + def test_native_member_never_asks_the_share_question(self): + # A member keeps exactly the old path: native permission, then the + # tightening chain. The qualification is a fallback, not a second vote. + lib.fixture_share_level(b'rw') + self.assertEqual(scalar(), 'rw') + self.assertEqual(lib.fixture_count(3), 0) + self.assertEqual(lib.fixture_count(2), 1) + + def test_a_failing_library_gate_is_propagated_not_requalified(self): + lib.fixture_fail_at(1) + lib.fixture_share_level(b'rw') + self.assertEqual(json.loads(scalar()), {'rpc_error': True}) + self.assertEqual(lib.fixture_count(3), 0) + + def test_managed_boundary_denial_is_not_reopened_by_a_share(self): + # Native rw plus the managed-library boundary: the legacy entry refuses + # because it cannot adjudicate that library. A share qualification must + # not turn that refusal into a grant. + lib.fixture_manage(1) + lib.fixture_share_level(b'rw') + self.assertIsNone(scalar()) + self.assertEqual(lib.fixture_count(3), 0) + + +if __name__ == '__main__': + unittest.main()