diff --git a/Cargo.lock b/Cargo.lock index b5edee248..1daaa0d14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7559,6 +7559,9 @@ dependencies = [ "axum", "chrono", "config-source", + "content-history", + "content-history-adapters", + "content-history-store", "feature-storage", "flate2", "flow", diff --git a/crates/content-history-store/tests/transactions.rs b/crates/content-history-store/tests/transactions.rs index 95e41a092..65be2f3ce 100644 --- a/crates/content-history-store/tests/transactions.rs +++ b/crates/content-history-store/tests/transactions.rs @@ -364,3 +364,104 @@ fn tampered_bytes_or_unsupported_format_fail_read_and_immutable_save() { .unwrap(); assert!(matches!(s.snapshot(head), Err(Error::Model(_)))); } + +#[test] +fn independent_connections_reject_stale_observer_without_replacing_winner() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("concurrent.sqlite"); + let mut first = Connection::open(&path).unwrap(); + setup(&mut first); + let mut second = Connection::open(&path).unwrap(); + let h = host("config"); + let initial = { + let tx = first.transaction().unwrap(); + let s = store(&tx, &h); + let (root, _) = candidate(&s, b"initial"); + let head = adopt(&s, root, None, 1); + tx.commit().unwrap(); + head + }; + // Both clients observe the same head before either submits its edit. + for conn in [&mut first, &mut second] { + let tx = conn.transaction().unwrap(); + assert_eq!(store(&tx, &h).head().unwrap(), Some(initial)); + tx.commit().unwrap(); + } + let winner = { + let tx = first.transaction().unwrap(); + let s = store(&tx, &h); + let (root, _) = candidate(&s, b"winner"); + let winner = adopt(&s, root, Some(initial), 2); + tx.commit().unwrap(); + winner + }; + let tx = second.transaction().unwrap(); + let s = store(&tx, &h); + let (root, _) = candidate(&s, b"loser"); + assert!(matches!( + s.adopt(root, Some(initial), "loser".into(), 3), + Err(Error::Conflict) + )); + assert_eq!(s.head().unwrap(), Some(winner)); + tx.commit().unwrap(); +} + +#[test] +fn deduplication_and_collection_preserve_other_preparations_and_all_adopted_ancestors() { + let mut conn = Connection::open_in_memory().unwrap(); + setup(&mut conn); + let a = host("a"); + let b = host("b"); + let tx = conn.transaction().unwrap(); + let sa = store(&tx, &a); + let sb = store(&tx, &b); + let (root, blob) = candidate(&sa, b"shared"); + let first = adopt(&sa, root, None, 1); + let count = || { + tx.query_row("SELECT count(*) FROM content_objects", [], |r| { + r.get::<_, i64>(0) + }) + .unwrap() + }; + assert_eq!(count(), 3); + candidate(&sa, b"shared"); + candidate(&sb, b"shared"); + assert_eq!(count(), 3); + let (new, _) = candidate(&sa, b"new"); + adopt(&sa, new, Some(first), 2); + candidate(&sa, b"unadopted"); + let before = count(); + sa.collect_unadopted().unwrap(); + assert_eq!(count(), before - 2); + assert!(sa.snapshot(first).unwrap().contains_key(&blob)); + sa.erase().unwrap(); + assert_eq!(count(), 2); // B's preparation survives A's erasure. + let b_head = adopt(&sb, root, None, 3); + assert!(sb.snapshot(b_head).unwrap().contains_key(&blob)); +} + +#[test] +fn revoked_authority_is_checked_again_on_existing_scoped_handle() { + use std::sync::atomic::{AtomicBool, Ordering}; + struct Revocable(AtomicBool); + impl Authority for Revocable { + fn allows(&self, _: &ScopeKey, _: Access) -> bool { + self.0.load(Ordering::SeqCst) + } + } + let mut conn = Connection::open_in_memory().unwrap(); + setup(&mut conn); + let authority = Revocable(AtomicBool::new(true)); + let tx = conn.transaction().unwrap(); + let s = ScopedStore::new(&tx, key("ws", "a"), &authority); + let (root, blob) = candidate(&s, b"protected"); + let head = adopt(&s, root, None, 1); + authority.0.store(false, Ordering::SeqCst); + assert!(matches!(s.read(blob), Err(Error::Denied))); + assert!(matches!(s.snapshot(head), Err(Error::Denied))); + assert!(matches!( + s.adopt(root, Some(head), "e".into(), 2), + Err(Error::Denied) + )); + assert!(matches!(s.erase(), Err(Error::Denied))); +} diff --git a/crates/content-history/src/model.rs b/crates/content-history/src/model.rs index bba521707..ac02423c9 100644 --- a/crates/content-history/src/model.rs +++ b/crates/content-history/src/model.rs @@ -291,7 +291,14 @@ impl Object { /// Decoding is intentionally first, so unsupported formats remain diagnosable. pub fn verify(id: ObjectId, bytes: &[u8]) -> Result { let object = Object::decode(bytes)?; - if object.id() != id { + // Hash the stored preimage, not a reserialization under the current writer's format. + let hash: [u8; 32] = Sha256::digest(bytes).into(); + let actual = match &object { + Object::Blob(_) => ObjectId::Blob(BlobId(hash)), + Object::Tree(_) => ObjectId::Tree(TreeId(hash)), + Object::History(_) => ObjectId::History(HistoryId(hash)), + }; + if actual != id { return Err(Error::HashMismatch); } Ok(object) diff --git a/crates/workspace-server/src/content_history_tests.rs b/crates/workspace-server/src/content_history_tests.rs index cd5176708..ebdf1ffdb 100644 --- a/crates/workspace-server/src/content_history_tests.rs +++ b/crates/workspace-server/src/content_history_tests.rs @@ -42,7 +42,7 @@ fn sources(value: &str) -> ConfigTreeSnapshot { ConfigEntry::new( VirtualPath::parse("parts/value.dcdl").unwrap(), ConfigContentType::Decodal, - format!("// exact comment\r\n{{ title = \"{value}\"; }}\r\n"), + format!("# exact comment\r\n{{ title = \"{value}\"; }}\r\n"), ) .unwrap(), ]) diff --git a/docs/report/content-history-rebuild-validation.md b/docs/report/content-history-rebuild-validation.md new file mode 100644 index 000000000..9f417ced7 --- /dev/null +++ b/docs/report/content-history-rebuild-validation.md @@ -0,0 +1,48 @@ +# 内容・編集履歴基盤の再構築検証 + +## 対象と境界 + +T-835 の本文 digest `9a896dbeee3c711895a56de73a3734c5d33967f4ac5688f2ec4f2cb70c09f6ae` を対象とし、指定基点 `d9295f44391a8cde7233231ad156f740e5f20097` から実装した。旧 T-822 の承認や試験結果は新候補の証拠として使わない。 + +純粋 model は内容と履歴を型付き ID・versioned canonical bytes で区別する。実 SQLite adapter は呼出し元の transaction を借り、認可済みの Workspace/対象から採用済み履歴に到達する読取りだけを許す。設定/Ticket adapter は別 crate の契約例であり、本番の設定採用・policy・移行、CLI、Ticket 編集 API・履歴 UI は変更しない。 + +永続 adapter の schema は本番 startup に登録していない。Server 接続試験は実 Server store を隔離 DB に作り、その既存 transaction helper 内で schema と契約例を接続する。本番採用側の正規 schema 登録・既存設定移行は T-838 の責務であり、本 fixture の成功で代替しない。認可 fixture は trusted Host の exact-target decision を代替し、HTTP の認証や production policy cutover の試験とは主張しない。 + +## AC と確認する境界 + +| AC | 所有する試験・観測 | +| --- | --- | +| 1 内容・構造・意味順・種別・形式 | model の固定 canonical bytes/SHA-256 vector、map 列挙順、sequence 順、厳密 decoder。adapter の設定/Ticket 固定 root | +| 2 旧全文復元・共有・構造差分 | model の copy-on-write と等しい部分木を読まない diff、追加/削除、store 再open と祖先復元 | +| 3 設定/Ticket 共通利用 | adapter の完全 source・明示 content type・directory hierarchy、投稿 ID/順/原著者の往復。Server fixture で別の業務状態を巻き戻さない | +| 4 履歴 CAS/ABA | 実 store の A→B→A、新 parent、新 history identity、2接続で同じ head を観測した編集の stale 拒否 | +| 5 永続 transaction | 実 SQLite の object/history/reachability/head 故障注入、caller rollback、再open。Server の source/projection/history 同 Tx・業務書込み失敗 rollback | +| 6 不完全/改変/互換 | decoder の全 truncate/trailing/非canonical/未知形式/不正 child、stored bytes 改変、immutable save 拒否、固定 v1 bytes の decode/hash 維持 | +| 7 認可/保持/消去 | 他 Workspace/他対象/hashのみ/未採用 object 拒否、handle 取消再検査、共有 dedup、未採用準備の GC、全祖先保持、明示消去と共有対象保持・旧 root 復活拒否 | +| 8 文書・関連・cross-target | crate README/rustdoc に所有境界と互換・消去限界。native と raw WASM の同じ固定 vector、関連 config crate、Server fixture、root check、format/命名/diff | + +## 実行記録 + +初期 checkpoint に対し `CARGO_INCREMENTAL=0 cargo check -j2 -p content-history-store -p content-history-adapters` が成功した。実 store の初回 `transactions` target は 7 件成功。model 作成途中の初回 `contracts` は 11 件成功・固定 fixture 件数不整合で 1 件失敗し、fixture を確定した。失敗を旧 green で置き換えない。 + +共有編集窓の引渡し後、Server dev-dependency に対応する確定 lock 差分を待つ段階で `cargo test --locked -j2 -p content-history --test contracts` は lock 更新要求により build 前に停止した。T-836 公開 `2998f53de76a6e44e911cf9ba3624ae22c2ae7bc` を直接確認し、Server の依存 3 行のみを採用した。他系列の source や依存は採用せず、lock を並行再生成していない。 + +最終候補の検証では、Rust コマンドに `CARGO_INCREMENTAL=0` と `-j2`、依存解決に `--locked` を使った。 + +- `cargo test -p content-history -p content-history-store -p content-history-adapters -p config-source -p config-source-wasm`:model 14、store 10、adapter 12、config-source 54、config-source-wasm 3 件と doctest が成功。 +- `cargo test -p yoi-workspace-server --lib content_history_tests`:初回は Ticket fixture 成功、設定 fixture が誤った Decodal コメント `//` により構文検証で失敗。fixture を正しい `#` へ修正した再実行で 2 件成功。Server の既存 dead-code 警告は残る。 +- root `cargo check`:TUI/Server を含む default closure 成功。 +- `RUSTDOCFLAGS=-Dwarnings cargo doc --no-deps -p content-history -p content-history-adapters -p content-history-store`:成功。 +- `cargo fmt --all -- --check`、`git diff --check HEAD`、状態契約命名検査、Deno parity runner の format/type check:成功。既存 config-source の TypeScript export 試験が出力した未整形ファイルはそのファイルだけ Deno format し、基点との差分がないことを確認。 + +### Native/WASM + +native 試験の compiler は Nix Rust 1.95.0(`59807616e`)。最初に公式 1.95.0 WASM std を隔離取得し、公表 SHA-256 `5587b89ff69623d09e476439d44a24453b4e4ea3d5e0b53a5c0a935151ff3fd1` と照合したが、Nix compiler と公式 std の metadata が非互換で E0514 となった。これは hash 不一致の観測ではない。 + +実 parity には既存の公式 Rust 1.96.0(`ac68faa20`)と対応する公式 WASM std を使い、std と build 出力を `target/t835-wasm/` 配下へ隔離した。配布物の公表 SHA-256 `5a360604f50aaf62320bd2201f6e97113bbdb2a93881c66889dd90cdbaab51a0` との一致を確認した。既存 toolchain の host linker wrapper が消失済 Nix path を参照していたため最初の host build-script link は失敗した。global toolchain は変更せず、検証専用 wrapper で host link のみ BFD を指定し、WASM link は既存 LLD を指定した。 + +`cargo build -p content-history --example wasm_parity --target wasm32-unknown-unknown` 後、`deno run --allow-read crates/content-history/tests/wasm_parity.ts ` を実行し、host import のない実 WASM module で **9 個の固定 object の全 digest word が native と同じ fixture に一致**した。build だけを parity 成功としていない。稼働 Server/Runtime、実 DB、global toolchain は更新していない。 + +## 未確認範囲 + +本番設定採用・移行、実 Ticket 編集 API、履歴 UI、HTTP 認証経路、稼働環境への適用、任意 OS crash/secure disk overwrite/backup 消去は本件の試験結果で証明しない。通常 Rust target は通常 Cargo runner の選択対象となる。raw WASM parity は明示的な cross-target 実行であり、CI での実行を確認したという記録ではない。