From 578d9fd51c73b5a70d4a3ebdbe0f627e710ead76 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Wed, 21 Oct 2026 01:21:02 +0800 Subject: [PATCH 1/8] fix(upload): qualify installed guard schema and stop invalid retries --- fileserver/cf_upload_guard_test.go | 142 +++++++++++++++++++++++++++++ fileserver/cf_v2_edit_fence.go | 51 ++++++++++- fileserver/fileop.go | 15 ++- 3 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 fileserver/cf_upload_guard_test.go diff --git a/fileserver/cf_upload_guard_test.go b/fileserver/cf_upload_guard_test.go new file mode 100644 index 00000000..6bcec15f --- /dev/null +++ b/fileserver/cf_upload_guard_test.go @@ -0,0 +1,142 @@ +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 + 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.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 lease_expires_at") + +func TestUploadGuardAcceptsInstalledLayoutsWithoutDisablingTheFence(t *testing.T) { + old := option.V2GuardAvailable + option.V2GuardAvailable = true + defer func() { option.V2GuardAvailable = old }() + 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 := option.V2GuardAvailable + option.V2GuardAvailable = true + defer func() { option.V2GuardAvailable = old }() + for _, f := range []*uploadGuardFixture{ + {columns: []string{"repo_id", "guard_id"}}, + {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() + } +} diff --git a/fileserver/cf_v2_edit_fence.go b/fileserver/cf_v2_edit_fence.go index d0d5bb24..6abb87a3 100644 --- a/fileserver/cf_v2_edit_fence.go +++ b/fileserver/cf_v2_edit_fence.go @@ -37,7 +37,11 @@ func cfV2EditCheckUnscopedBranch(ctx context.Context, tx *sql.Tx, repoID, oldCom if !option.V2GuardAvailable { return nil } - rows, err := tx.QueryContext(ctx, "SELECT path FROM cf_edit_guard FORCE INDEX (cf_edit_guard_active) WHERE repo_id=? AND guard_id IS NOT NULL AND lease_expires_at>UTC_TIMESTAMP(6) FOR UPDATE", repoID) + query, err := cfEditGuardQuery(ctx, tx) + if err != nil { + return errCFEditGuard + } + rows, err := tx.QueryContext(ctx, query, repoID) if err != nil { return errCFEditGuard } @@ -87,3 +91,48 @@ func cfV2EditCheckUnscopedBranch(ctx context.Context, tx *sql.Tx, repoID, oldCom } return nil } + +// The deployed Checkout store and the optional path-keyed Rust store share a +// table name but not a schema. Select by actual columns before issuing SQL; +// never turn a failed query into permission to skip an installed Guard. +func cfEditGuardQuery(ctx context.Context, tx *sql.Tx) (string, error) { + rows, err := tx.QueryContext(ctx, "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='cf_edit_guard'") + if err != nil { + return "", errCFEditGuard + } + columns := make(map[string]bool) + for rows.Next() { + var column string + if rows.Scan(&column) != nil || len(columns) >= 64 { + rows.Close() + return "", errCFEditGuard + } + columns[column] = true + } + err = rows.Err() + rows.Close() + if err != nil { + return "", errCFEditGuard + } + has := func(names ...string) bool { + for _, name := range names { + if !columns[name] { + return false + } + } + return true + } + pathStore := has("repo_id", "path_hash", "path", "guard_id", "lease_expires_at") + resourceStore := has("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") + if pathStore && !resourceStore { + return "SELECT path FROM cf_edit_guard FORCE INDEX (cf_edit_guard_active) WHERE repo_id=? AND guard_id IS NOT NULL AND lease_expires_at>UTC_TIMESTAMP(6) FOR UPDATE", nil + } + if resourceStore && !pathStore { + // Match the native Checkout barrier: every non-null Guard remains a + // fence until its lifecycle is reconciled, including expired leases. + // A missing, stale or foreign resource yields NULL and is refused by + // the path scanner; an inner join would silently lose that fence. + return "SELECT CASE WHEN r.repo_id=g.repo_id AND r.lifecycle_ref=g.lifecycle_ref AND r.kind='file' AND r.state='active' THEN r.path ELSE NULL END FROM cf_edit_guard g LEFT JOIN cf_resource r ON r.uid=g.resource_uid WHERE g.repo_id=? AND g.guard_id IS NOT NULL FOR UPDATE", nil + } + return "", errCFEditGuard +} diff --git a/fileserver/fileop.go b/fileserver/fileop.go index 6b0cfae6..ac41b182 100644 --- a/fileserver/fileop.go +++ b/fileserver/fileop.go @@ -1950,6 +1950,10 @@ func postMultiFiles(rsp http.ResponseWriter, r *http.Request, repoID, parentDir, }) if errors.Is(err, ErrGCConflict) { return &appError{nil, "GC Conflict.\n", http.StatusConflict} + } else if errors.Is(err, errCFEditGuard) { + // An installed Guard cannot be queried or rejects the publication; + // report that failure directly instead of a fabricated retry timeout. + return &appError{nil, "The server cannot verify the Checkout guard.", http.StatusServiceUnavailable} } else { err := fmt.Errorf("failed to post files and gen commit: %v", err) return &appError{err, "", http.StatusInternalServerError} @@ -2265,11 +2269,13 @@ func genCommitNeedRetry(repo *repomgr.Repo, base *commitmgr.Commit, commit *comm if gcConflict { return false, err } - if err == errCFACL { - return false, err + // Only a changed Branch head is a concurrent-publication retry. Guard, + // schema and authority failures are deterministic, not ten hidden retries. + if errors.Is(err, ErrConflict) { + return true, nil } if err != nil { - return true, nil + return false, err } if commitID != nil { @@ -2341,8 +2347,7 @@ func updateBranch(repoID, originRepoID, newCommitID, oldCommitID, secondParentID } if oldCommitID != commitID { trans.Rollback() - err := fmt.Errorf("head commit id has changed") - return false, err + return false, ErrConflict } // Final and transactional: an upload preflight cannot protect against a From 92616b43a58712caed0b3da25e75abe8a84d8a54 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Wed, 21 Oct 2026 01:32:37 +0800 Subject: [PATCH 2/8] fix(upload): reject incomplete and mixed guard layouts --- fileserver/cf_upload_guard_test.go | 6 +++++- fileserver/cf_v2_edit_fence.go | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/fileserver/cf_upload_guard_test.go b/fileserver/cf_upload_guard_test.go index 6bcec15f..7b0a1ebf 100644 --- a/fileserver/cf_upload_guard_test.go +++ b/fileserver/cf_upload_guard_test.go @@ -79,7 +79,7 @@ func (r *uploadGuardRows) Next(dest []driver.Value) error { } 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 lease_expires_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 := option.V2GuardAvailable @@ -122,6 +122,10 @@ func TestUploadGuardRefusesUnknownAmbiguousOrUnavailableState(t *testing.T) { defer func() { option.V2GuardAvailable = old }() 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}, diff --git a/fileserver/cf_v2_edit_fence.go b/fileserver/cf_v2_edit_fence.go index 6abb87a3..fe82fed3 100644 --- a/fileserver/cf_v2_edit_fence.go +++ b/fileserver/cf_v2_edit_fence.go @@ -122,8 +122,10 @@ func cfEditGuardQuery(ctx context.Context, tx *sql.Tx) (string, error) { } return true } - pathStore := has("repo_id", "path_hash", "path", "guard_id", "lease_expires_at") - resourceStore := has("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") + // Require the complete reviewed layout, matching Hub qualification. A + // partial or mixed table must not be mistaken for a valid Guard store. + pathStore := len(columns) == 15 && has("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") + resourceStore := len(columns) == 16 && has("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") if pathStore && !resourceStore { return "SELECT path FROM cf_edit_guard FORCE INDEX (cf_edit_guard_active) WHERE repo_id=? AND guard_id IS NOT NULL AND lease_expires_at>UTC_TIMESTAMP(6) FOR UPDATE", nil } From 7d3f83d655f3ec49cb2bf26b508ac4aeddbba148 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Wed, 21 Oct 2026 02:05:12 +0800 Subject: [PATCH 3/8] docs(schema): define four-table manual upgrade boundary --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 41ae169a..712788a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 分支、表名存在或旧台账自动推断升级路径,不自动回滚。 From c24bf67823b5ab0846c47709e04b25c2a84dc131 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Wed, 21 Oct 2026 02:25:51 +0800 Subject: [PATCH 4/8] fix(upload): skip Guard SQL when Checkout is disabled --- fileserver/cf_upload_guard_test.go | 55 +++++++++++++++++++++++++---- fileserver/cf_v2_edit_agent.go | 10 +++--- fileserver/cf_v2_edit_agent_test.go | 25 ++++++++----- fileserver/cf_v2_edit_fence.go | 7 +++- fileserver/fileserver.go | 29 ++++++++++----- fileserver/option/checkout_test.go | 30 ++++++++++++++++ fileserver/option/option.go | 17 +++++++-- 7 files changed, 142 insertions(+), 31 deletions(-) create mode 100644 fileserver/option/checkout_test.go diff --git a/fileserver/cf_upload_guard_test.go b/fileserver/cf_upload_guard_test.go index 7b0a1ebf..495dfdae 100644 --- a/fileserver/cf_upload_guard_test.go +++ b/fileserver/cf_upload_guard_test.go @@ -18,6 +18,7 @@ type uploadGuardFixture struct { columns []string paths []driver.Value failMetadata, failGuard bool + tableCount int queries []string } type uploadGuardDriver struct{} @@ -39,7 +40,12 @@ func (c *uploadGuardConn) Begin() (driver.Tx, error) { return uploadGu 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.COLUMNS") { + 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") } @@ -82,9 +88,9 @@ var resourceGuardColumns = strings.Fields("resource_uid repo_id lifecycle_ref gu 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 := option.V2GuardAvailable - option.V2GuardAvailable = true - defer func() { option.V2GuardAvailable = old }() + 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 @@ -117,9 +123,9 @@ func TestUploadGuardAcceptsInstalledLayoutsWithoutDisablingTheFence(t *testing.T } func TestUploadGuardRefusesUnknownAmbiguousOrUnavailableState(t *testing.T) { - old := option.V2GuardAvailable - option.V2GuardAvailable = true - defer func() { option.V2GuardAvailable = old }() + 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. @@ -144,3 +150,38 @@ func TestUploadGuardRefusesUnknownAmbiguousOrUnavailableState(t *testing.T) { 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() + } +} diff --git a/fileserver/cf_v2_edit_agent.go b/fileserver/cf_v2_edit_agent.go index 6440aa7e..26ff73cf 100644 --- a/fileserver/cf_v2_edit_agent.go +++ b/fileserver/cf_v2_edit_agent.go @@ -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 @@ -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 { @@ -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 } diff --git a/fileserver/cf_v2_edit_agent_test.go b/fileserver/cf_v2_edit_agent_test.go index 6cd1e57f..5d2a48c6 100644 --- a/fileserver/cf_v2_edit_agent_test.go +++ b/fileserver/cf_v2_edit_agent_test.go @@ -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", @@ -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) { diff --git a/fileserver/cf_v2_edit_fence.go b/fileserver/cf_v2_edit_fence.go index fe82fed3..19f0ebad 100644 --- a/fileserver/cf_v2_edit_fence.go +++ b/fileserver/cf_v2_edit_fence.go @@ -34,9 +34,14 @@ func cfV2EditTouchesPath(entry *diff.DiffEntry, guardPath string) bool { // Caller must already hold Branch(master) FOR UPDATE in this same transaction. // SQL errors and native diff failures deny, never silently fall back to CE. func cfV2EditCheckUnscopedBranch(ctx context.Context, tx *sql.Tx, repoID, oldCommitID, newCommitID string) error { - if !option.V2GuardAvailable { + // Ordinary upload must not depend on Guard schema or SQL when Checkout + // is disabled. Enabled-but-unavailable is an error, never a bypass. + if !option.CloudFileCheckoutEnabled { return nil } + if !option.V2GuardAvailable { + return errCFEditGuard + } query, err := cfEditGuardQuery(ctx, tx) if err != nil { return errCFEditGuard diff --git a/fileserver/fileserver.go b/fileserver/fileserver.go index 88496303..423338dd 100644 --- a/fileserver/fileserver.go +++ b/fileserver/fileserver.go @@ -162,18 +162,31 @@ func loadSeafileDB() { seafileDB.SetMaxIdleConns(8) } -// A controlled Guard schema migration requires restarting native services. -// An unhealthy database is fatal: it must never be interpreted as no Checkout. +// A capability change requires restarting Hub and native services. Disabled +// Checkout never queries a Guard table, even when the baseline DDL includes it. +func loadV2GuardCapability(ctx context.Context, db *sql.DB) (bool, error) { + if !option.CloudFileCheckoutEnabled { + return false, nil + } + var count int + if err := db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='cf_edit_guard'").Scan(&count); err != nil { + return false, fmt.Errorf("Checkout Guard discovery failed") + } + if count != 1 { + return false, fmt.Errorf("enabled Checkout requires an installed Guard table") + } + return true, nil +} + func detectV2Guard() { ctx, cancel := context.WithTimeout(context.Background(), option.DBOpTimeout) defer cancel() - var count int - err := seafileDB.QueryRowContext(ctx, - "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='cf_edit_guard'").Scan(&count) - if err != nil || count < 0 || count > 1 { - log.Fatalf("Failed to determine installed CloudFile Checkout Guard: %v (count=%d)", err, count) + available, err := loadV2GuardCapability(ctx, seafileDB) + if err != nil { + log.Fatal(err) } - option.V2GuardAvailable = count == 1 + option.V2GuardAvailable = available } func writePidFile(pid_file_path string) error { diff --git a/fileserver/option/checkout_test.go b/fileserver/option/checkout_test.go new file mode 100644 index 00000000..a6ff6c27 --- /dev/null +++ b/fileserver/option/checkout_test.go @@ -0,0 +1,30 @@ +package option + +import ( + "os" + "path/filepath" + "testing" +) + +// The existing Docker setting must be read explicitly; a table cannot enable it. +func TestCheckoutRequiresExplicitConfiguration(t *testing.T) { + old := CloudFileCheckoutEnabled + defer func() { CloudFileCheckoutEnabled = old }() + for _, tc := range []struct { + value string + want bool + }{{"", false}, {"false", false}, {"true", true}} { + dir := t.TempDir() + config := "[fileserver]\nport = 8082\n[cloudfile]\n" + if tc.value != "" { + config += "checkout_enabled = " + tc.value + "\n" + } + if err := os.WriteFile(filepath.Join(dir, "seafile.conf"), []byte(config), 0600); err != nil { + t.Fatal(err) + } + LoadFileServerOptions(dir) + if CloudFileCheckoutEnabled != tc.want { + t.Fatalf("setting %q: got %v", tc.value, CloudFileCheckoutEnabled) + } + } +} diff --git a/fileserver/option/option.go b/fileserver/option/option.go index d8dcdc38..aa213e70 100644 --- a/fileserver/option/option.go +++ b/fileserver/option/option.go @@ -24,8 +24,11 @@ var CloudFileManagedLibraryGuard bool var CloudFileAllowLegacyManagedReads bool var CloudFileTrustedTLSProxies []*net.IPNet -// Startup schema detection sets this once before serving requests. Keeping the -// default false preserves ordinary edits when Checkout Guard is not installed. +// Checkout is an explicit existing capability, not implied by a table name. +// Both values are initialized before serving requests; disabled means no Guard SQL. +var CloudFileCheckoutEnabled bool + +// True only after enabled Checkout has a usable installed Guard table. var V2GuardAvailable bool // Storage unit. @@ -151,7 +154,17 @@ func LoadFileServerOptions(centralDir string) { CloudFileTrustedTLSProxies = nil CloudFileManagedLibraryGuard = false CloudFileAllowLegacyManagedReads = false + CloudFileCheckoutEnabled = false if section, sectionErr := config.GetSection("cloudfile"); sectionErr == nil { + // Reuse CF_ENABLE_CHECKOUT -> [cloudfile] checkout_enabled from Docker. + // Invalid values must not silently disable an explicitly requested fence. + if key, keyErr := section.GetKey("checkout_enabled"); keyErr == nil { + var parseErr error + CloudFileCheckoutEnabled, parseErr = key.Bool() + if parseErr != nil { + log.Fatal("Invalid CloudFile checkout_enabled") + } + } if key, keyErr := section.GetKey("allow_legacy_managed_reads"); keyErr == nil { var parseErr error CloudFileAllowLegacyManagedReads, parseErr = key.Bool() From 54c2f09cb181019669999d5162c05d8e2e9f69bf Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Wed, 21 Oct 2026 02:47:13 +0800 Subject: [PATCH 5/8] fix(checkout): remove legacy tables from native write barriers --- README.testing.md | 1 + common/cf-lock.c | 48 ++++++++------------ common/cf-lock.h | 4 +- server/cloudfile-policy.c | 43 +++++------------- tests/cf-lock/fixture.c | 92 +++++++++++++++++++++++++++++++++++++++ tests/cf-lock/run.py | 26 +++++++++++ 6 files changed, 152 insertions(+), 62 deletions(-) create mode 100644 tests/cf-lock/fixture.c create mode 100644 tests/cf-lock/run.py diff --git a/README.testing.md b/README.testing.md index 081d6e53..331c8a5b 100644 --- a/README.testing.md +++ b/README.testing.md @@ -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 开发包 diff --git a/common/cf-lock.c b/common/cf-lock.c index 24db4932..1a235768 100644 --- a/common/cf-lock.c +++ b/common/cf-lock.c @@ -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; @@ -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; @@ -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; @@ -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"); } } diff --git a/common/cf-lock.h b/common/cf-lock.h index 009094e7..8ae77467 100644 --- a/common/cf-lock.h +++ b/common/cf-lock.h @@ -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 -/* 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); diff --git a/server/cloudfile-policy.c b/server/cloudfile-policy.c index 5ee96780..086aaf74 100644 --- a/server/cloudfile-policy.c +++ b/server/cloudfile-policy.c @@ -218,46 +218,27 @@ lease_resource_location (SeafDBTrans *trans, const char *repo, return count >= 0 && count <= 1 && location.valid && (!uid || count == 1) ? 0 : -1; } -/* Only the opt-in lock barrier requires the new checkout schema. The final - * Branch transaction must re-read it after PREPARE, closing that race for - * scoped file publication. A missing or malformed table fails closed. */ -static int -editing_conflict (SeafDBTrans *trans, const char *repo, const char *path, - const char *hash, const char *user) -{ - gboolean error = FALSE; - gboolean guarded = seaf_db_trans_check_for_existence (trans, - "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.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<>?) FOR UPDATE", - &error, 4, "string", repo, "string", hash, "string", path, - "string", user ? user : ""); - return guarded || error ? -1 : 0; -} - int cf_policy_check_unleased_write (SeafDBTrans *trans, const char *repo, const char *path, const char *user) { if (!cf_lock_enabled ()) return 0; + (void)user; if (!repo || !path || path[0] != '/' || strlen (path) > 4096 || - !g_utf8_validate (path, -1, NULL) || editing_schema (trans) < 0) return -1; + !g_utf8_validate (path, -1, NULL)) return -1; char *digest = g_compute_checksum_for_string (G_CHECKSUM_SHA256, path, -1); if (!digest) return -1; - if (lease_resource_location (trans, repo, path, digest, NULL) < 0) { - g_free (digest); - return -1; - } - /* Current locking read, not a preflight/RR snapshot. The actual sparse UID - * and lease rows stay locked until the caller's Branch transaction ends. - * SQL errors fail closed; orphan/lifecycle reconciliation remains a - * separate native integration prerequisite, not proven by this join. - * Exact stored path is compared after hashing to avoid digest-only identity. - */ - int result = editing_conflict (trans, repo, path, digest, user); + /* V2 publishes against one path-keyed Guard, in the Branch transaction. + * The retired Resource/Intent ledger must not be a file-write dependency. + * Unscoped owner writes also refuse an active exclusive reservation. */ + gboolean error = FALSE; + gboolean guarded = seaf_db_trans_check_for_existence (trans, + "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) FOR UPDATE", + &error, 3, "string", repo, "string", digest, "string", path); g_free (digest); - return result; + return guarded || error ? -1 : 0; } int diff --git a/tests/cf-lock/fixture.c b/tests/cf-lock/fixture.c new file mode 100644 index 00000000..512fc656 --- /dev/null +++ b/tests/cf-lock/fixture.c @@ -0,0 +1,92 @@ +/* Production barrier/dispatcher; fake config and database only, no network. */ +#include +#include +#include "cf-fileop.h" +#include "cf-lock.h" +#include "seafile-error.h" +#include "log.h" +typedef int SeafDB; +typedef int SeafDBRow; +typedef int SeafDBTrans; +static gboolean transaction_error; +static SeafDB database; +static gboolean checkout; +static int sql_calls, exact_rows, descendant_rows; +static gboolean cf_ext_config_bool(const char *key) +{ + /* A stale file_lock_enabled=true must not activate Checkout. */ + return !strcmp(key, "checkout_enabled") ? checkout : TRUE; +} +static char *cf_ext_config_string(const char *key) { return NULL; } +static SeafDB *cf_ext_database(void) { return &database; } +static int seaf_db_statement_foreach_row(SeafDB *db, const char *sql, + gboolean (*callback)(SeafDBRow *, void *), void *data, int count, ...) +{ + ++sql_calls; + g_assert_nonnull(strstr(sql,"FROM cf_edit_guard")); + g_assert_nonnull(strstr(sql,"cf_edit_guard_active")); + g_assert_nonnull(strstr(sql,"lease_expires_at>UTC_TIMESTAMP(6)")); + g_assert_null(strstr(sql,"cf_resource")); + g_assert_null(strstr(sql,"cf_schema_migration")); + if (strstr(sql,"path_hash=UNHEX(?)")) return exact_rows; + g_assert_nonnull(strstr(sql,"path LIKE ?")); + return descendant_rows; +} +static gboolean seaf_db_trans_check_for_existence(SeafDBTrans *trans, + const char *sql, gboolean *error, int count, ...) +{ + ++sql_calls; + g_assert_nonnull(strstr(sql,"FROM cf_edit_guard")); + g_assert_nonnull(strstr(sql,"path_hash=UNHEX(?)")); + g_assert_nonnull(strstr(sql,"FOR UPDATE")); + g_assert_null(strstr(sql,"cf_resource")); + *error = transaction_error; + return exact_rows > 0; +} +#include "production.c" + +static int write_path(const char *path, GError **error) +{ + CfFileOp op = {.op=CF_OP_UPDATE_FILE,.repo_id="fixture",.dir=path,.user="owner"}; + return cf_fileop_prepare(&op,error); +} +int main(void) +{ + GError *error = NULL; + checkout=FALSE; cf_lock_init(); + g_assert_false(cf_lock_enabled()); + g_assert_cmpint(write_path("/file",&error),==,0); + g_assert_cmpint(sql_calls,==,0); + checkout=TRUE; cf_lock_init(); + g_assert_true(cf_lock_enabled()); + g_assert_cmpint(write_path("/file",&error),==,0); + g_assert_cmpint(sql_calls,==,2); + exact_rows=1; + g_assert_cmpint(write_path("/file",&error),==,-1); + g_assert_error(error,SEAFILE_DOMAIN,CF_ERR_FILE_LOCKED); g_clear_error(&error); + exact_rows=0; descendant_rows=1; + g_assert_cmpint(write_path("/parent",&error),==,-1); + g_assert_error(error,SEAFILE_DOMAIN,CF_ERR_FILE_LOCKED); g_clear_error(&error); + descendant_rows=0; exact_rows=-1; + g_assert_cmpint(write_path("/file",&error),==,-1); + g_assert_error(error,SEAFILE_DOMAIN,SEAF_ERR_GENERAL); g_clear_error(&error); + exact_rows=0; descendant_rows=-1; + g_assert_cmpint(write_path("/parent",&error),==,-1); + g_assert_error(error,SEAFILE_DOMAIN,SEAF_ERR_GENERAL); g_clear_error(&error); + checkout=FALSE; cf_fileop_reset(); cf_lock_init(); sql_calls=0; + g_assert_cmpint(write_path("/file",&error),==,0); + g_assert_cmpint(sql_calls,==,0); + /* The final Branch transaction uses the real production function too. */ + g_assert_cmpint(cf_policy_check_unleased_write(NULL,"fixture","/file","owner"),==,0); + g_assert_cmpint(sql_calls,==,0); + checkout=TRUE; cf_lock_init(); exact_rows=0; + g_assert_cmpint(cf_policy_check_unleased_write(NULL,"fixture","/file","owner"),==,0); + exact_rows=1; + g_assert_cmpint(cf_policy_check_unleased_write(NULL,"fixture","/file","owner"),==,-1); + exact_rows=0; transaction_error=TRUE; + g_assert_cmpint(cf_policy_check_unleased_write(NULL,"fixture","/file","owner"),==,-1); + transaction_error=FALSE; + g_assert_cmpint(cf_policy_check_unleased_write(NULL,"fixture","relative","owner"),==,-1); + g_print("PASS 12 native Checkout barrier and final-transaction cases\n"); + return 0; +} diff --git a/tests/cf-lock/run.py b/tests/cf-lock/run.py new file mode 100644 index 00000000..3b31a066 --- /dev/null +++ b/tests/cf-lock/run.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Compile the complete production lock barrier and real fileop dispatcher. + +Only config/database I/O is doubled. This proves disabled Checkout issues zero +SQL and active reservations/SQL failures refuse native unscoped writes. +""" +from pathlib import Path +import re +import subprocess +import tempfile + +root = Path(__file__).resolve().parents[2] +with tempfile.TemporaryDirectory(prefix='cf-lock-test-') as folder: + build = Path(folder) + # Keep every production statement; replace only external include boundaries. + source = re.sub(r'^#include[^\n]*\n', '', (root/'common/cf-lock.c').read_text(), flags=re.M) + policy = (root/'server/cloudfile-policy.c').read_text() + start = policy.index('int\ncf_policy_check_unleased_write (') + end = policy.index('\n}\n', start) + 3 + (build/'production.c').write_text(source + '\n' + policy[start:end]) + flags = subprocess.check_output(['pkg-config','--cflags','--libs','glib-2.0'], text=True).split() + subprocess.run(['cc','-std=c99','-Wall','-Wextra','-Werror','-Wno-unused-parameter', + '-I'+str(build),'-I'+str(root/'common'),'-I'+str(root/'include'), + str(root/'tests/cf-lock/fixture.c'),str(root/'common/cf-fileop.c'), + str(root/'common/cf-path.c'),'-o',str(build/'test'),*flags],check=True) + subprocess.run([str(build/'test')],check=True) From f48eb678668c192b9777e4cd8152845935a5d9b3 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Wed, 21 Oct 2026 07:41:29 +0800 Subject: [PATCH 6/8] fix(checkout): fail startup when the installed Guard contract is not V2 The opt-in model only probed table existence, so a legacy resource-keyed cf_edit_guard or a missing cf_edit_guard_active index turned every upload into opaque SQL 1176 retries. Validate the reviewed column/index contract once at startup when Checkout is enabled and report CF_EDIT_SCHEMA_MISMATCH with the SQL package to apply. Disabled Checkout still performs no Guard query at all. The contract test covers the legacy 029 columns, text path_hash, missing and reversed active index and the old resource primary key. --- fileserver/cf_v2_edit_schema.go | 142 +++++++++++++++++++++++++++ fileserver/cf_v2_edit_schema_test.go | 63 ++++++++++++ fileserver/fileserver.go | 9 ++ 3 files changed, 214 insertions(+) create mode 100644 fileserver/cf_v2_edit_schema.go create mode 100644 fileserver/cf_v2_edit_schema_test.go diff --git a/fileserver/cf_v2_edit_schema.go b/fileserver/cf_v2_edit_schema.go new file mode 100644 index 00000000..5ec9ee23 --- /dev/null +++ b/fileserver/cf_v2_edit_schema.go @@ -0,0 +1,142 @@ +package main + +// Modification: validate the *installed* cf_edit_guard column/index contract at +// fileserver startup whenever Checkout is explicitly enabled. +// Reason: the opt-in model only probed table existence, so a legacy +// resource-keyed table or a missing cf_edit_guard_active index surfaced as an +// opaque SQL 1176 failure on every upload. The schema-strict variant showed an +// explicit CF_EDIT_SCHEMA_MISMATCH is actionable for operators; this file folds +// that evidence into the opt-in model without making disabled Checkout depend +// on any Guard SQL. +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +type cfGuardColumn struct { + kind string + length int64 + nullable string + unsigned bool +} + +type cfGuardIndexPart struct { + name string + nonUnique int64 + prefix bool +} + +// Reviewed V2 Guard shape. Keep in sync with +// database/v2/20261021-four-table-baseline/00-create.sql; a table name alone is +// not a schema contract. +var cfV2GuardColumns = map[string]cfGuardColumn{ + "repo_id": {"char", 36, "NO", false}, + "path_hash": {"binary", 32, "NO", false}, + "path": {"varchar", 4096, "NO", false}, + "guard_id": {"char", 36, "YES", false}, + "generation": {"bigint", 0, "NO", true}, + "owner_uid": {"varchar", 225, "YES", false}, + "owner_native_user": {"varchar", 255, "YES", false}, + "terminal_type": {"varchar", 16, "YES", false}, + "terminal_name": {"varchar", 128, "YES", false}, + "checkout_ip": {"varchar", 45, "YES", false}, + "proof_digest": {"char", 64, "YES", false}, + "base_file_id": {"char", 40, "YES", false}, + "lease_expires_at": {"datetime", 0, "YES", false}, + "created_at": {"datetime", 0, "NO", false}, + "updated_at": {"datetime", 0, "NO", false}, +} + +var cfV2GuardIndexes = map[string][]cfGuardIndexPart{ + "PRIMARY": {{"repo_id", 0, false}, {"path_hash", 0, false}}, + "cf_edit_guard_id": {{"guard_id", 0, false}}, + "cf_edit_guard_active": {{"repo_id", 1, false}, {"guard_id", 1, false}}, + "cf_edit_guard_owner": {{"owner_uid", 1, false}, {"guard_id", 1, false}}, +} + +// cfV2GuardValidate compares a metadata snapshot with the reviewed contract. +// It performs no data reads and issues no DDL/DML. +func cfV2GuardValidate(columns map[string]cfGuardColumn, indexes map[string][]cfGuardIndexPart) error { + if len(columns) != len(cfV2GuardColumns) { + return fmt.Errorf("expected %d V2 Guard columns, found %d", len(cfV2GuardColumns), len(columns)) + } + for name, want := range cfV2GuardColumns { + got, exists := columns[name] + if !exists || got != want { + return fmt.Errorf("Guard column %s is missing or incompatible", name) + } + } + for name, want := range cfV2GuardIndexes { + got := indexes[name] + if len(got) != len(want) { + return fmt.Errorf("Guard index %s is missing or incompatible", name) + } + for i := range want { + if got[i] != want[i] { + return fmt.Errorf("Guard index %s has incompatible key order or uniqueness", name) + } + } + } + return nil +} + +// cfV2GuardRequireCurrent reads only information_schema metadata and returns an +// error naming the first incompatible column or index. Callers surface it as a +// startup failure so a legacy table is archived before Checkout is enabled +// instead of being retried per upload. +func cfV2GuardRequireCurrent(ctx context.Context, db *sql.DB) error { + rows, err := db.QueryContext(ctx, + "SELECT COLUMN_NAME,DATA_TYPE,CHARACTER_MAXIMUM_LENGTH,IS_NULLABLE,COLUMN_TYPE "+ + "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='cf_edit_guard'") + if err != nil { + return fmt.Errorf("cannot inspect Guard columns: %w", err) + } + columns := make(map[string]cfGuardColumn) + for rows.Next() { + var name, kind, nullable, fullKind string + var length sql.NullInt64 + if err := rows.Scan(&name, &kind, &length, &nullable, &fullKind); err != nil { + rows.Close() + return fmt.Errorf("cannot read Guard column definition: %w", err) + } + columns[name] = cfGuardColumn{kind, length.Int64, nullable, + strings.Contains(strings.ToLower(fullKind), "unsigned")} + } + err = rows.Err() + rows.Close() + if err != nil { + return fmt.Errorf("cannot finish Guard column inspection: %w", err) + } + + rows, err = db.QueryContext(ctx, + "SELECT INDEX_NAME,SEQ_IN_INDEX,COLUMN_NAME,NON_UNIQUE,SUB_PART "+ + "FROM information_schema.STATISTICS "+ + "WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='cf_edit_guard' "+ + "ORDER BY INDEX_NAME,SEQ_IN_INDEX") + if err != nil { + return fmt.Errorf("cannot inspect Guard indexes: %w", err) + } + indexes := make(map[string][]cfGuardIndexPart) + for rows.Next() { + var key, name string + var order, nonUnique int64 + var prefix sql.NullInt64 + if err := rows.Scan(&key, &order, &name, &nonUnique, &prefix); err != nil { + rows.Close() + return fmt.Errorf("cannot read Guard index definition: %w", err) + } + if order != int64(len(indexes[key])+1) { + rows.Close() + return fmt.Errorf("Guard index %s key order is invalid", key) + } + indexes[key] = append(indexes[key], cfGuardIndexPart{name, nonUnique, prefix.Valid}) + } + err = rows.Err() + rows.Close() + if err != nil { + return fmt.Errorf("cannot finish Guard index inspection: %w", err) + } + return cfV2GuardValidate(columns, indexes) +} diff --git a/fileserver/cf_v2_edit_schema_test.go b/fileserver/cf_v2_edit_schema_test.go new file mode 100644 index 00000000..1a4dedc3 --- /dev/null +++ b/fileserver/cf_v2_edit_schema_test.go @@ -0,0 +1,63 @@ +package main + +// Contract tests for the startup Guard validation added on top of the opt-in +// Checkout model. They exercise metadata-only validation, so no database is +// required; the failure cases mirror the legacy 029 layout and index drift +// that previously surfaced as SQL 1176 retries. + +import ( + "testing" +) + +func guardLayout() (map[string]cfGuardColumn, map[string][]cfGuardIndexPart) { + columns := make(map[string]cfGuardColumn) + for name, column := range cfV2GuardColumns { + columns[name] = column + } + indexes := make(map[string][]cfGuardIndexPart) + for name, parts := range cfV2GuardIndexes { + indexes[name] = append([]cfGuardIndexPart(nil), parts...) + } + return columns, indexes +} + +func TestCFV2GuardSchemaContract(t *testing.T) { + columns, indexes := guardLayout() + if err := cfV2GuardValidate(columns, indexes); err != nil { + t.Fatalf("current V2 Guard rejected: %v", err) + } + for _, tc := range []struct { + name string + edit func(map[string]cfGuardColumn, map[string][]cfGuardIndexPart) + }{ + {"old lifecycle_ref instead of path", func(c map[string]cfGuardColumn, _ map[string][]cfGuardIndexPart) { + delete(c, "path") + c["lifecycle_ref"] = cfGuardColumn{kind: "varchar", length: 512, nullable: "NO"} + }}, + {"old lease_until instead of lease_expires_at", func(c map[string]cfGuardColumn, _ map[string][]cfGuardIndexPart) { + delete(c, "lease_expires_at") + c["lease_until"] = cfGuardColumn{kind: "datetime", nullable: "YES"} + }}, + {"path hash stored as text", func(c map[string]cfGuardColumn, _ map[string][]cfGuardIndexPart) { + c["path_hash"] = cfGuardColumn{kind: "char", length: 64, nullable: "NO"} + }}, + {"missing active index", func(_ map[string]cfGuardColumn, idx map[string][]cfGuardIndexPart) { + delete(idx, "cf_edit_guard_active") + }}, + {"reversed active index", func(_ map[string]cfGuardColumn, idx map[string][]cfGuardIndexPart) { + idx["cf_edit_guard_active"][0], idx["cf_edit_guard_active"][1] = + idx["cf_edit_guard_active"][1], idx["cf_edit_guard_active"][0] + }}, + {"old resource key", func(_ map[string]cfGuardColumn, idx map[string][]cfGuardIndexPart) { + idx["PRIMARY"] = []cfGuardIndexPart{{name: "resource_uid", nonUnique: 0}} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + c, i := guardLayout() + tc.edit(c, i) + if err := cfV2GuardValidate(c, i); err == nil { + t.Fatal("outdated or incompatible Guard layout passed") + } + }) + } +} diff --git a/fileserver/fileserver.go b/fileserver/fileserver.go index 423338dd..907c51e8 100644 --- a/fileserver/fileserver.go +++ b/fileserver/fileserver.go @@ -176,6 +176,15 @@ func loadV2GuardCapability(ctx context.Context, db *sql.DB) (bool, error) { if count != 1 { return false, fmt.Errorf("enabled Checkout requires an installed Guard table") } + // Modification: validate the installed column/index contract before serving. + // Reason: a present table is not a usable schema. A legacy resource-keyed + // table or a missing cf_edit_guard_active index previously failed every + // upload with SQL 1176; fail once at startup with an operator instruction + // instead, without making disabled Checkout read Guard metadata at all. + if err := cfV2GuardRequireCurrent(ctx, db); err != nil { + return false, fmt.Errorf("CF_EDIT_SCHEMA_MISMATCH: installed cf_edit_guard is not the reviewed V2 layout (%v); "+ + "archive the legacy rows and apply the reviewed database/v2 SQL package before enabling Checkout", err) + } return true, nil } From 50fca9feb523ad23b68af54115544be5e88091b8 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Wed, 21 Oct 2026 07:54:23 +0800 Subject: [PATCH 7/8] refactor(upload): name the only retryable commit failure Extract cfBranchConflictRetryable so the retry contract is testable, and add a regression test that Guard, schema, ACL, SQL and nil failures are never retried. No behaviour change: genCommitNeedRetry already retried only ErrConflict. --- fileserver/cf_upload_retry_test.go | 31 ++++++++++++++++++++++++++++++ fileserver/fileop.go | 11 ++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 fileserver/cf_upload_retry_test.go diff --git a/fileserver/cf_upload_retry_test.go b/fileserver/cf_upload_retry_test.go new file mode 100644 index 00000000..2e58592e --- /dev/null +++ b/fileserver/cf_upload_retry_test.go @@ -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) + } + } +} diff --git a/fileserver/fileop.go b/fileserver/fileop.go index ac41b182..106a390f 100644 --- a/fileserver/fileop.go +++ b/fileserver/fileop.go @@ -2212,6 +2212,15 @@ func fastForwardOrMerge(user, token string, repo *repomgr.Repo, base, newCommit return nil } +// cfBranchConflictRetryable names the only transient commit failure: another +// writer moved the Branch HEAD. Guard, schema, ACL and SQL failures are +// deterministic until the deployment is repaired, so retrying them only turns +// one visible fault into ten hidden ones. Kept as a helper so the contract is +// testable without a database. +func cfBranchConflictRetryable(err error) bool { + return errors.Is(err, ErrConflict) +} + func genCommitNeedRetry(repo *repomgr.Repo, base *commitmgr.Commit, commit *commitmgr.Commit, newRoot, user string, handleConncurrentUpdate bool, commitID *string, lastGCID string, checkGC bool) (bool, error) { var secondParentID string repoID := repo.ID @@ -2271,7 +2280,7 @@ func genCommitNeedRetry(repo *repomgr.Repo, base *commitmgr.Commit, commit *comm } // Only a changed Branch head is a concurrent-publication retry. Guard, // schema and authority failures are deterministic, not ten hidden retries. - if errors.Is(err, ErrConflict) { + if cfBranchConflictRetryable(err) { return true, nil } if err != nil { From b16a392d5c96993cd274d77d3546fdfa7be74ac8 Mon Sep 17 00:00:00 2001 From: dev9-bb Date: Wed, 21 Oct 2026 08:26:46 +0800 Subject: [PATCH 8/8] refactor(checkout): use only the reviewed path Guard at commit time Startup already validates the 15-column/4-index path contract when Checkout is enabled, so the final Branch transaction no longer re-reads information_schema and the resource-keyed 029 fallback is removed. Runtime drift still fails closed: any query error returns errCFEditGuard and the write is refused. --- fileserver/cf_upload_guard_test.go | 68 ++++++++++++------------------ fileserver/cf_v2_edit_fence.go | 61 +++++---------------------- 2 files changed, 36 insertions(+), 93 deletions(-) diff --git a/fileserver/cf_upload_guard_test.go b/fileserver/cf_upload_guard_test.go index 495dfdae..697ac284 100644 --- a/fileserver/cf_upload_guard_test.go +++ b/fileserver/cf_upload_guard_test.go @@ -84,59 +84,43 @@ func (r *uploadGuardRows) Next(dest []driver.Value) error { 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) { +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 }() - cases := []struct { - name string - columns []string - marker string - }{ - {"resource Checkout", resourceGuardColumns, "LEFT JOIN cf_resource"}, - {"path Checkout", pathGuardColumns, "FORCE INDEX (cf_edit_guard_active)"}, + f := &uploadGuardFixture{} + db := sql.OpenDB(uploadGuardConnector{f}) + defer db.Close() + tx, err := db.Begin() + if err != nil { + t.Fatal(err) } - 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]) - } - }) + 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 TestUploadGuardRefusesUnknownAmbiguousOrUnavailableState(t *testing.T) { +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{ - {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"}}, + {failGuard: true}, + {paths: []driver.Value{nil}}, + {paths: []driver.Value{"relative/path"}}, } { db := sql.OpenDB(uploadGuardConnector{f}) tx, err := db.Begin() diff --git a/fileserver/cf_v2_edit_fence.go b/fileserver/cf_v2_edit_fence.go index 19f0ebad..cf7d2a9d 100644 --- a/fileserver/cf_v2_edit_fence.go +++ b/fileserver/cf_v2_edit_fence.go @@ -42,11 +42,11 @@ func cfV2EditCheckUnscopedBranch(ctx context.Context, tx *sql.Tx, repoID, oldCom if !option.V2GuardAvailable { return errCFEditGuard } - query, err := cfEditGuardQuery(ctx, tx) - if err != nil { - return errCFEditGuard - } - rows, err := tx.QueryContext(ctx, query, repoID) + // The startup gate qualified the installed 15-column/4-index path store + // when Checkout was enabled, so the final transaction uses the reviewed + // query directly instead of re-reading information_schema on every commit. + // Runtime drift still denies: a failed query returns errCFEditGuard. + rows, err := tx.QueryContext(ctx, cfEditGuardFenceSQL, repoID) if err != nil { return errCFEditGuard } @@ -97,49 +97,8 @@ func cfV2EditCheckUnscopedBranch(ctx context.Context, tx *sql.Tx, repoID, oldCom return nil } -// The deployed Checkout store and the optional path-keyed Rust store share a -// table name but not a schema. Select by actual columns before issuing SQL; -// never turn a failed query into permission to skip an installed Guard. -func cfEditGuardQuery(ctx context.Context, tx *sql.Tx) (string, error) { - rows, err := tx.QueryContext(ctx, "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='cf_edit_guard'") - if err != nil { - return "", errCFEditGuard - } - columns := make(map[string]bool) - for rows.Next() { - var column string - if rows.Scan(&column) != nil || len(columns) >= 64 { - rows.Close() - return "", errCFEditGuard - } - columns[column] = true - } - err = rows.Err() - rows.Close() - if err != nil { - return "", errCFEditGuard - } - has := func(names ...string) bool { - for _, name := range names { - if !columns[name] { - return false - } - } - return true - } - // Require the complete reviewed layout, matching Hub qualification. A - // partial or mixed table must not be mistaken for a valid Guard store. - pathStore := len(columns) == 15 && has("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") - resourceStore := len(columns) == 16 && has("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") - if pathStore && !resourceStore { - return "SELECT path FROM cf_edit_guard FORCE INDEX (cf_edit_guard_active) WHERE repo_id=? AND guard_id IS NOT NULL AND lease_expires_at>UTC_TIMESTAMP(6) FOR UPDATE", nil - } - if resourceStore && !pathStore { - // Match the native Checkout barrier: every non-null Guard remains a - // fence until its lifecycle is reconciled, including expired leases. - // A missing, stale or foreign resource yields NULL and is refused by - // the path scanner; an inner join would silently lose that fence. - return "SELECT CASE WHEN r.repo_id=g.repo_id AND r.lifecycle_ref=g.lifecycle_ref AND r.kind='file' AND r.state='active' THEN r.path ELSE NULL END FROM cf_edit_guard g LEFT JOIN cf_resource r ON r.uid=g.resource_uid WHERE g.repo_id=? AND g.guard_id IS NOT NULL FOR UPDATE", nil - } - return "", errCFEditGuard -} +// Reviewed V2 fence query. Only this path-keyed store is supported; the +// resource-keyed 029 layout is archived by the reviewed SQL package and must +// not remain reachable as a runtime fallback. +const cfEditGuardFenceSQL = "SELECT path FROM cf_edit_guard FORCE INDEX (cf_edit_guard_active) " + + "WHERE repo_id=? AND guard_id IS NOT NULL AND lease_expires_at>UTC_TIMESTAMP(6) FOR UPDATE"