Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,9 @@ CloudFile Server、Hub、Docker 共用以下规则:
## 代码习惯

代码和提交信息用英文,中文维护文档;C 保持上游风格,RPC 优先 `cf_` 前缀;明确内存所有权。不要提交构建产物、密钥、开发环境临时文件。

## 开发期数据库变更(2026-10-21)

- 新版当前只明确新增四张业务表:`cf_library_storage`、`cf_dir_acl`、`cf_sso_group_map`、`cf_edit_guard`。历史表仅作备份或迁移输入,不属于新版运行库及运行依赖;增加业务表须先确认,历史数据单独归档,不直接删除。
- 开发期建表、升级及数据迁移通过 Docker 仓 `database/` 下独立、人工执行的 SQL 交付,DEV 验证与 PROD 发布复用固定脚本及校验和。启动/重启仅校验,不自动执行扩展 DDL、旧迁移链或兼容补丁。
- 仅明确审核的成熟版本之间才考虑自动升级;须定义精确源/目标范围和数据/中断恢复验证。不得根据 dev 分支、表名存在或旧台账自动推断升级路径,不自动回滚。
1 change: 1 addition & 0 deletions README.testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
```bash
./tests/cf-acl/run.sh
./tests/cf-fileop/run.sh
python3 tests/cf-lock/run.py
./tests/cf-permission-many/run.sh
./tests/cf-dir-share/run.sh
# 目录分页另需 valac / libjansson 开发包
Expand Down
48 changes: 19 additions & 29 deletions common/cf-lock.c
Original file line number Diff line number Diff line change
Expand Up @@ -66,23 +66,19 @@ like_descendant_pattern (const char *path)
return g_string_free (pattern, FALSE);
}

/* Lock and resource share the Hub migration's UID and seafile-db. The SQL
* retains manual locks and suspended checkout reservations.
* A failed query refuses the write rather than weakening the barrier. */
/* V2 reservations are path-keyed. Unscoped writes, including the owner's,
* must not consume an exclusive Checkout without a verified publish proof. */
static int
load_exact (const char *repo_id, const char *path, const char *user)
{
(void)user;
char *hash = g_compute_checksum_for_string (G_CHECKSUM_SHA256, path, -1);
int n = 0;
if (n == 0)
n = seaf_db_statement_foreach_row (
cf_ext_database (),
"SELECT r.uid FROM cf_resource r JOIN cf_edit_guard g ON g.resource_uid=r.uid "
"WHERE r.repo_id=? AND r.path_hash=? AND r.path=? AND r.kind='file' AND r.state='active' "
"AND g.repo_id=r.repo_id AND g.guard_id IS NOT NULL "
"AND (g.lifecycle_ref<>r.lifecycle_ref OR g.mode IS NULL OR g.mode<>'file-lock' OR g.owner_native_user IS NULL OR g.owner_native_user<>?) LIMIT 1",
barrier_row, NULL, 4, "string", repo_id, "string", hash,
"string", path, "string", user ? user : "");
int n = seaf_db_statement_foreach_row (
cf_ext_database (),
"SELECT path FROM cf_edit_guard FORCE INDEX (cf_edit_guard_active) "
"WHERE repo_id=? AND path_hash=UNHEX(?) AND path=? "
"AND guard_id IS NOT NULL AND lease_expires_at>UTC_TIMESTAMP(6) LIMIT 1",
barrier_row, NULL, 3, "string", repo_id, "string", hash, "string", path);

g_free (hash);
return n;
Expand All @@ -92,14 +88,12 @@ static int
load_descendant (const char *repo_id, const char *path)
{
char *pattern = like_descendant_pattern (path);
int n = 0;
if (n == 0)
n = seaf_db_statement_foreach_row (
cf_ext_database (),
"SELECT r.uid FROM cf_resource r JOIN cf_edit_guard g ON g.resource_uid=r.uid "
"WHERE r.repo_id=? AND r.path LIKE ? ESCAPE '\\\\' AND r.state='active' "
"AND g.repo_id=r.repo_id AND g.guard_id IS NOT NULL LIMIT 1",
barrier_row, NULL, 2, "string", repo_id, "string", pattern);
int n = seaf_db_statement_foreach_row (
cf_ext_database (),
"SELECT path FROM cf_edit_guard FORCE INDEX (cf_edit_guard_active) "
"WHERE repo_id=? AND path LIKE ? ESCAPE '\\\\' "
"AND guard_id IS NOT NULL AND lease_expires_at>UTC_TIMESTAMP(6) LIMIT 1",
barrier_row, NULL, 2, "string", repo_id, "string", pattern);

g_free (pattern);
return n;
Expand All @@ -112,12 +106,6 @@ check_path (const char *repo_id, const char *path, const char *user,
int n;
if (!cf_ext_database ())
goto unavailable;
int ready = seaf_db_statement_foreach_row (
cf_ext_database (),
"SELECT version FROM cf_schema_migration WHERE version='029_editing_core' "
"AND state='applied' AND step=2", barrier_row, NULL, 0);
if (ready != 1)
goto unavailable;
n = load_exact (repo_id, path, user);
if (n < 0)
goto unavailable;
Expand Down Expand Up @@ -176,13 +164,15 @@ void
cf_lock_init (void)
{
char *backend = cf_ext_config_string ("lock_backend");
lock_on = cf_ext_config_bool ("file_lock_enabled") &&
/* Checkout is unfinished and opt-in. A legacy file-lock switch alone
* must not make normal uploads depend on Guard or the retired ledger. */
lock_on = cf_ext_config_bool ("checkout_enabled") &&
(!backend || strcmp (backend, "cloudfile") == 0);
if (backend && strcmp (backend, "cloudfile") != 0)
seaf_warning ("CloudFile: unsupported lock backend '%s'.\n", backend);
g_free (backend);
if (lock_on) {
cf_fileop_register ("file-lock", cf_lock_prepare, NULL, NULL);
seaf_message ("CloudFile: UID lease write barrier enabled.\n");
seaf_message ("CloudFile: V2 Checkout write barrier enabled.\n");
}
}
4 changes: 2 additions & 2 deletions common/cf-lock.h
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/* -*- Mode: C; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */

/* CloudFile's Hub-owned UID lease write barrier. */
/* CloudFile's path-keyed V2 Checkout write barrier. */

#ifndef CF_LOCK_H
#define CF_LOCK_H

#include <glib.h>

/* Registers the write-lifecycle provider when [cloudfile] file_lock_enabled
/* Registers the write-lifecycle provider when [cloudfile] checkout_enabled
* is true and lock_backend is either unset or "cloudfile". */
void cf_lock_init (void);

Expand Down
187 changes: 187 additions & 0 deletions fileserver/cf_upload_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package main

import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"io"
"strings"
"testing"

"github.com/haiwen/seafile-server/fileserver/option"
)

// Exercise production SQL selection and row validation through database/sql.
// This fixture does not certify MySQL lock behavior or a deployed upload.
type uploadGuardFixture struct {
columns []string
paths []driver.Value
failMetadata, failGuard bool
tableCount int
queries []string
}
type uploadGuardDriver struct{}

func (uploadGuardDriver) Open(string) (driver.Conn, error) { return nil, errors.New("use connector") }

type uploadGuardConnector struct{ fixture *uploadGuardFixture }

func (c uploadGuardConnector) Driver() driver.Driver { return uploadGuardDriver{} }
func (c uploadGuardConnector) Connect(context.Context) (driver.Conn, error) {
return &uploadGuardConn{c.fixture}, nil
}

type uploadGuardConn struct{ fixture *uploadGuardFixture }

func (c *uploadGuardConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("no prepare") }
func (c *uploadGuardConn) Close() error { return nil }
func (c *uploadGuardConn) Begin() (driver.Tx, error) { return uploadGuardTx{}, nil }
func (c *uploadGuardConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
c.fixture.queries = append(c.fixture.queries, query)
rows := &uploadGuardRows{}
if strings.Contains(query, "information_schema.TABLES") {
if c.fixture.failMetadata {
return nil, errors.New("database unavailable")
}
rows.values = append(rows.values, []driver.Value{int64(c.fixture.tableCount)})
} else if strings.Contains(query, "information_schema.COLUMNS") {
if c.fixture.failMetadata {
return nil, errors.New("database unavailable")
}
for _, name := range c.fixture.columns {
rows.values = append(rows.values, []driver.Value{name})
}
} else {
if c.fixture.failGuard {
return nil, errors.New("guard unavailable")
}
if len(args) != 1 || args[0].Value != "repo" {
return nil, errors.New("wrong repository scope")
}
for _, path := range c.fixture.paths {
rows.values = append(rows.values, []driver.Value{path})
}
}
return rows, nil
}

type uploadGuardTx struct{}

func (uploadGuardTx) Commit() error { return nil }
func (uploadGuardTx) Rollback() error { return nil }

type uploadGuardRows struct{ values [][]driver.Value }

func (r *uploadGuardRows) Columns() []string { return []string{"value"} }
func (r *uploadGuardRows) Close() error { return nil }
func (r *uploadGuardRows) Next(dest []driver.Value) error {
if len(r.values) == 0 {
return io.EOF
}
copy(dest, r.values[0])
r.values = r.values[1:]
return nil
}

var resourceGuardColumns = strings.Fields("resource_uid repo_id lifecycle_ref guard_id generation mode owner owner_native_user source holder credential_epoch proof_digest base_file_id pending_intent lease_until hard_expire_at")
var pathGuardColumns = strings.Fields("repo_id path_hash path guard_id generation owner_uid owner_native_user terminal_type terminal_name checkout_ip proof_digest base_file_id lease_expires_at created_at updated_at")

func TestUploadGuardAcceptsInstalledLayoutsWithoutDisablingTheFence(t *testing.T) {
old, oldEnabled := option.V2GuardAvailable, option.CloudFileCheckoutEnabled
option.V2GuardAvailable, option.CloudFileCheckoutEnabled = true, true
defer func() { option.V2GuardAvailable, option.CloudFileCheckoutEnabled = old, oldEnabled }()
cases := []struct {
name string
columns []string
marker string
}{
{"resource Checkout", resourceGuardColumns, "LEFT JOIN cf_resource"},
{"path Checkout", pathGuardColumns, "FORCE INDEX (cf_edit_guard_active)"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := &uploadGuardFixture{columns: tc.columns}
db := sql.OpenDB(uploadGuardConnector{f})
defer db.Close()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
if err := cfV2EditCheckUnscopedBranch(context.Background(), tx, "repo", "old", "new"); err != nil {
t.Fatal(err)
}
if len(f.queries) != 2 || !strings.Contains(f.queries[1], tc.marker) || !strings.Contains(f.queries[1], "FOR UPDATE") {
t.Fatalf("wrong final guard query: %v", f.queries)
}
if tc.name == "resource Checkout" && (strings.Contains(f.queries[1], "lease_until>") || !strings.Contains(f.queries[1], "ELSE NULL")) {
t.Fatalf("stale or orphan fence can disappear: %s", f.queries[1])
}
})
}
}

func TestUploadGuardRefusesUnknownAmbiguousOrUnavailableState(t *testing.T) {
old, oldEnabled := option.V2GuardAvailable, option.CloudFileCheckoutEnabled
option.V2GuardAvailable, option.CloudFileCheckoutEnabled = true, true
defer func() { option.V2GuardAvailable, option.CloudFileCheckoutEnabled = old, oldEnabled }()
for _, f := range []*uploadGuardFixture{
{columns: []string{"repo_id", "guard_id"}},
// The previously accepted five-column subset is not the installed DDL.
{columns: []string{"repo_id", "path_hash", "path", "guard_id", "lease_expires_at"}},
{columns: append(append([]string{}, pathGuardColumns...), "resource_uid")},
{columns: append(append([]string{}, resourceGuardColumns...), "path")},
{columns: append(append([]string{}, resourceGuardColumns...), pathGuardColumns...)},
{columns: resourceGuardColumns, failMetadata: true},
{columns: resourceGuardColumns, failGuard: true},
{columns: resourceGuardColumns, paths: []driver.Value{nil}},
{columns: resourceGuardColumns, paths: []driver.Value{"relative/path"}},
} {
db := sql.OpenDB(uploadGuardConnector{f})
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if !errors.Is(cfV2EditCheckUnscopedBranch(context.Background(), tx, "repo", "old", "new"), errCFEditGuard) {
t.Fatalf("unavailable or malformed guard was bypassed: %+v", f)
}
tx.Rollback()
db.Close()
}
}

// Nil DB/transaction proves the disabled path performs no metadata or Guard SQL.
func TestUploadCheckoutDisabledNeverQueriesGuard(t *testing.T) {
old, enabled := option.V2GuardAvailable, option.CloudFileCheckoutEnabled
defer func() { option.V2GuardAvailable, option.CloudFileCheckoutEnabled = old, enabled }()
option.CloudFileCheckoutEnabled, option.V2GuardAvailable = false, true
if available, err := loadV2GuardCapability(context.Background(), nil); err != nil || available {
t.Fatalf("disabled capability touched the database: %v", err)
}
if err := cfV2EditCheckUnscopedBranch(context.Background(), nil, "repo", "old", "new"); err != nil {
t.Fatalf("disabled upload queried Guard: %v", err)
}
option.CloudFileCheckoutEnabled, option.V2GuardAvailable = true, false
if !errors.Is(cfV2EditCheckUnscopedBranch(context.Background(), nil, "repo", "old", "new"), errCFEditGuard) {
t.Fatal("enabled but unavailable Guard was bypassed")
}
}

func TestUploadEnabledCheckoutRequiresInstalledGuard(t *testing.T) {
enabled := option.CloudFileCheckoutEnabled
option.CloudFileCheckoutEnabled = true
defer func() { option.CloudFileCheckoutEnabled = enabled }()
for _, f := range []*uploadGuardFixture{{tableCount: 1}, {tableCount: 0}, {failMetadata: true}} {
db := sql.OpenDB(uploadGuardConnector{f})
available, err := loadV2GuardCapability(context.Background(), db)
if f.tableCount == 1 && !f.failMetadata {
if err != nil || !available {
t.Fatal("installed Guard not enabled")
}
} else if err == nil || available {
t.Fatal("enabled Checkout accepted unavailable Guard")
}
db.Close()
}
}
10 changes: 5 additions & 5 deletions fileserver/cf_v2_edit_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,9 @@ func cfEditStatus(w http.ResponseWriter, r *http.Request, c *cfEditClaims, repo
}
}
out["sha256"] = hex.EncodeToString(hasher.Sum(nil))
if option.V2GuardAvailable {
// Checkout is schema-derived; ordinary optimistic editing is always available.
// A V2 deployment without the Guard table never queries it.
if option.CloudFileCheckoutEnabled && option.V2GuardAvailable {
// Checkout is explicitly enabled and its Guard is installed.
// Disabled Checkout leaves ordinary optimistic editing independent of Guard SQL.
var gID, owner sql.NullString
var generation uint64
var expires sql.NullTime
Expand Down Expand Up @@ -506,7 +506,7 @@ func cfEditPublish(w http.ResponseWriter, r *http.Request, c *cfEditClaims, repo
if action == "optimistic" {
// No persistent lock or Intent is allocated for an ordinary save.
// The Guard query is needed only on deployments with an installed Guard.
if option.V2GuardAvailable {
if option.CloudFileCheckoutEnabled && option.V2GuardAvailable {
var held int
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM cf_edit_guard WHERE repo_id=? AND path_hash=? AND guard_id IS NOT NULL AND lease_expires_at>UTC_TIMESTAMP(6) FOR UPDATE", repo.ID, cfEditHash(c.FilePath)).Scan(&held)
if err != nil || held != 0 {
Expand Down Expand Up @@ -603,7 +603,7 @@ func cfEditAPI(w http.ResponseWriter, r *http.Request) {
cfEditFail(w, 403, "READ_ONLY_TICKET")
return
}
if !option.V2GuardAvailable && !cfEditAllowedWithoutCheckout(c.Mode, route, r.Header.Get("X-CF-Action")) {
if (!option.CloudFileCheckoutEnabled || !option.V2GuardAvailable) && !cfEditAllowedWithoutCheckout(c.Mode, route, r.Header.Get("X-CF-Action")) {
cfEditFail(w, 409, "CHECKOUT_UNSUPPORTED")
return
}
Expand Down
25 changes: 17 additions & 8 deletions fileserver/cf_v2_edit_agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,14 @@ func TestCFEditLapsedGuardResumesOnlyOnUnchangedFile(t *testing.T) {
}

func TestCFEditExclusiveUnavailableWithoutCheckoutSchema(t *testing.T) {
oldFlag, oldSecret := option.V2GuardAvailable, option.JWTPrivateKey
oldFlag, oldSecret, oldEnabled := option.V2GuardAvailable, option.JWTPrivateKey, option.CloudFileCheckoutEnabled
option.V2GuardAvailable = false
option.JWTPrivateKey = "unit-test-secret"
defer func() { option.V2GuardAvailable = oldFlag; option.JWTPrivateKey = oldSecret }()
defer func() {
option.V2GuardAvailable = oldFlag
option.JWTPrivateKey = oldSecret
option.CloudFileCheckoutEnabled = oldEnabled
}()
now := time.Now()
ticket := cfEditClaims{
UID: "uid-test",
Expand All @@ -135,13 +139,18 @@ func TestCFEditExclusiveUnavailableWithoutCheckoutSchema(t *testing.T) {
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/cf-edit/v1/checkout", nil)
req.Header.Set("Authorization", "Bearer "+signed)
rec := httptest.NewRecorder()
cfEditAPI(rec, req)
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "CHECKOUT_UNSUPPORTED") {
t.Fatalf("unexpected disabled checkout response: %d %s", rec.Code, rec.Body.String())
// A table alone cannot enable Checkout or allow its API to create a lock.
for _, flags := range []struct{ enabled, available bool }{{false, true}, {true, false}} {
option.CloudFileCheckoutEnabled, option.V2GuardAvailable = flags.enabled, flags.available
req := httptest.NewRequest("POST", "/cf-edit/v1/checkout", nil)
req.Header.Set("Authorization", "Bearer "+signed)
rec := httptest.NewRecorder()
cfEditAPI(rec, req)
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "CHECKOUT_UNSUPPORTED") {
t.Fatalf("unexpected disabled checkout response: %d %s", rec.Code, rec.Body.String())
}
}

}

func TestCFEditOrdinaryRequiresNoCheckoutTable(t *testing.T) {
Expand Down
Loading
Loading