Skip to content
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
171 changes: 171 additions & 0 deletions fileserver/cf_upload_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
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
}

func TestUploadGuardUsesReviewedPathQueryOnly(t *testing.T) {
old, oldEnabled := option.V2GuardAvailable, option.CloudFileCheckoutEnabled
option.V2GuardAvailable, option.CloudFileCheckoutEnabled = true, true
defer func() { option.V2GuardAvailable, option.CloudFileCheckoutEnabled = old, oldEnabled }()
f := &uploadGuardFixture{}
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)
}
// Startup already qualified the schema: the commit path issues exactly one
// Guard query, with no information_schema read and no cf_resource join.
if len(f.queries) != 1 {
t.Fatalf("expected one guard query, got %v", f.queries)
}
query := f.queries[0]
if strings.Contains(query, "information_schema") || strings.Contains(query, "cf_resource") {
t.Fatalf("legacy or repeated metadata query on commit path: %s", query)
}
if !strings.Contains(query, "FORCE INDEX (cf_edit_guard_active)") || !strings.Contains(query, "lease_expires_at>") {
t.Fatalf("guard query lost its reviewed shape: %s", query)
}
}

func TestUploadGuardRefusesGuardFailures(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{
{failGuard: true},
{paths: []driver.Value{nil}},
{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()
}
}
31 changes: 31 additions & 0 deletions fileserver/cf_upload_retry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package main

// Regression guard for the retry contract: only a moved Branch HEAD may be
// retried. Without this test a refactor could silently turn a schema/Guard
// fault into ten upload attempts reported as "concurrent upload".

import (
"errors"
"fmt"
"testing"
)

func TestCFUploadRetriesOnlyHeadConflict(t *testing.T) {
if !cfBranchConflictRetryable(ErrConflict) {
t.Fatal("direct HEAD conflict must remain retryable")
}
if !cfBranchConflictRetryable(fmt.Errorf("wrapped: %w", ErrConflict)) {
t.Fatal("wrapped HEAD conflict must remain retryable")
}
for _, err := range []error{
errCFEditGuard,
errCFACL,
errors.New("Error 1176 (42000): Key 'cf_edit_guard_active' doesn't exist in table"),
errors.New("SQL unavailable"),
nil,
} {
if cfBranchConflictRetryable(err) {
t.Fatalf("deterministic failure must never retry: %v", err)
}
}
}
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