1use crate::storage;
2use rusqlite::{params, Transaction};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::{
6 collections::HashSet,
7 fs,
8 io::Read,
9 path::{Path, PathBuf},
10 sync::{Mutex, OnceLock},
11};
12
13const FILE: &str = "identities.json";
14
15struct ValidatedManifest {
16 path: PathBuf,
17 workspace_id: String,
18 stamp: String,
19 revision: Option<(u64, String)>,
20}
21
22static VALIDATED: OnceLock<Mutex<Vec<ValidatedManifest>>> = OnceLock::new();
27
28#[cfg(test)]
29thread_local! { static MANIFEST_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; }
30
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct IdentityFile {
34 pub path: String,
35 pub id: String,
36 pub hash: String,
37 pub version: i64,
38 pub deleted: bool,
39}
40
41#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct IdentityManifest {
44 pub format: u32,
45 pub workspace_id: String,
46 pub revision: u64,
47 pub files: Vec<IdentityFile>,
48 pub reserved_ids: Vec<String>,
49}
50
51fn path(root: &Path) -> std::path::PathBuf {
52 root.join(".tano").join(FILE)
53}
54
55fn active(root: &Path, workspace_id: &str) -> Result<bool, String> {
56 Ok(storage::existing_workspace_identity(root)?.as_deref() == Some(workspace_id))
57}
58
59pub fn require(root: &Path, workspace_id: &str) -> Result<(), String> {
60 storage::assert_workspace_identity(root, workspace_id)?;
61 let file = path(root);
62 if !file.is_file() {
63 return Err("工作区身份清单已丢失,已停止写入;请检查 .tano/identities.json".into());
64 }
65 let before = storage::scan_cache::state(&file).stamp;
66 let validated = VALIDATED.get_or_init(|| Mutex::new(Vec::new()));
67 if !before.is_empty()
68 && validated.lock().is_ok_and(|entries| {
69 entries.iter().any(|entry| {
70 entry.path == file && entry.workspace_id == workspace_id && entry.stamp == before
71 })
72 })
73 {
74 return Ok(());
75 }
76 read(root, workspace_id)?
77 .ok_or("工作区身份清单已丢失,请检查 .tano/identities.json".to_string())?;
78 remember_validation(file, workspace_id, before, None);
79 Ok(())
80}
81
82fn remember_validation(
83 file: PathBuf,
84 workspace_id: &str,
85 before: String,
86 revision: Option<(u64, String)>,
87) {
88 if !before.is_empty() && storage::scan_cache::state(&file).stamp == before {
90 if let Ok(mut entries) = VALIDATED.get_or_init(|| Mutex::new(Vec::new())).lock() {
91 entries.retain(|entry| entry.path != file);
92 if entries.len() >= 8 {
93 entries.remove(0);
94 }
95 entries.push(ValidatedManifest {
96 path: file,
97 workspace_id: workspace_id.into(),
98 stamp: before,
99 revision,
100 });
101 }
102 }
103}
104
105pub fn read(root: &Path, workspace_id: &str) -> Result<Option<IdentityManifest>, String> {
106 #[cfg(test)]
107 MANIFEST_READS.with(|count| count.set(count.get() + 1));
108 let file = path(root);
109 let metadata = fs::symlink_metadata(&file);
110 let metadata = match metadata {
111 Ok(metadata) => metadata,
112 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
113 Err(error) => return Err(error.to_string()),
114 };
115 if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 16 * 1024 * 1024
116 {
117 return Err("工作区身份清单必须是不超过 16 MiB 的普通文件".into());
118 }
119 let manifest: IdentityManifest =
120 serde_json::from_slice(&fs::read(&file).map_err(|e| e.to_string())?)
121 .map_err(|e| format!("工作区身份清单损坏,原文件已保留:{e}"))?;
122 validate(&manifest, workspace_id)?;
123 Ok(Some(manifest))
124}
125
126pub(crate) fn validate(manifest: &IdentityManifest, workspace_id: &str) -> Result<(), String> {
127 if manifest.format != 1
128 || manifest.workspace_id != workspace_id
129 || manifest.revision == 0
130 || manifest.files.len() > 20_000
131 || manifest.reserved_ids.len() > 100_000
132 {
133 return Err("工作区身份清单格式、空间身份或数量无效".into());
134 }
135 let mut paths = HashSet::new();
136 let mut active_ids = HashSet::new();
137 let reserved: HashSet<_> = manifest.reserved_ids.iter().collect();
138 if reserved.len() != manifest.reserved_ids.len()
139 || reserved.iter().any(|id| !storage::is_short_id(id))
140 {
141 return Err("身份清单包含重复或无效占号".into());
142 }
143 for file in &manifest.files {
144 storage::validate_relative_path(&file.path, storage::is_workspace_data_path(&file.path))?;
145 if !storage::is_short_id(&file.id)
146 || !reserved.contains(&file.id)
147 || (!file.hash.is_empty()
148 && (file.hash.len() != 64 || !file.hash.bytes().all(|c| c.is_ascii_hexdigit())))
149 || file.version < 1
150 || !paths.insert(&file.path)
151 || (!file.deleted && !active_ids.insert(&file.id))
152 {
153 return Err("工作区身份清单中的文件身份、路径或 hash 无效".into());
154 }
155 }
156 Ok(())
157}
158
159fn bytes(manifest: &IdentityManifest) -> Result<Vec<u8>, String> {
160 serde_json::to_vec_pretty(manifest).map_err(|e| e.to_string())
161}
162
163fn cache_revision(
164 transaction: &Transaction<'_>,
165 workspace_id: &str,
166) -> Result<Option<(u64, String)>, String> {
167 transaction.execute_batch("CREATE TABLE IF NOT EXISTS identity_revisions (workspace_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, hash TEXT NOT NULL)")
168 .map_err(|e| e.to_string())?;
169 let result = transaction.query_row(
170 "SELECT revision, hash FROM identity_revisions WHERE workspace_id=?",
171 [workspace_id],
172 |row| Ok((row.get::<_, u64>(0)?, row.get(1)?)),
173 );
174 match result {
175 Ok(value) => Ok(Some(value)),
176 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
177 Err(error) => Err(error.to_string()),
178 }
179}
180
181fn set_cache_revision(
182 transaction: &Transaction<'_>,
183 workspace_id: &str,
184 revision: u64,
185 hash: &str,
186) -> Result<(), String> {
187 transaction.execute("INSERT INTO identity_revisions VALUES (?1,?2,?3) ON CONFLICT(workspace_id) DO UPDATE SET revision=excluded.revision, hash=excluded.hash",
188 params![workspace_id, revision, hash]).map_err(|e| e.to_string())?;
189 Ok(())
190}
191
192pub fn hydrate(
193 transaction: &Transaction<'_>,
194 root: &Path,
195 workspace_id: &str,
196) -> Result<(), String> {
197 if !active(root, workspace_id)? {
198 return Ok(());
199 }
200 let prior_revision = cache_revision(transaction, workspace_id)?;
201 let file = path(root);
202 let before = storage::scan_cache::state(&file).stamp;
203 if !before.is_empty()
204 && prior_revision.is_some()
205 && VALIDATED
206 .get_or_init(|| Mutex::new(Vec::new()))
207 .lock()
208 .is_ok_and(|entries| {
209 entries.iter().any(|entry| {
210 entry.path == file
211 && entry.workspace_id == workspace_id
212 && entry.stamp == before
213 && entry.revision == prior_revision
214 })
215 })
216 {
217 return Ok(());
218 }
219 let Some(manifest) = read(root, workspace_id)? else {
220 if prior_revision.is_some() {
221 return Err("工作区身份清单已丢失,已停止扫描;请检查 .tano/identities.json".into());
222 }
223 return Ok(());
224 };
225 let hash = storage::hash(&bytes(&manifest)?);
226 remember_validation(
227 file,
228 workspace_id,
229 before,
230 Some((manifest.revision, hash.clone())),
231 );
232 if prior_revision == Some((manifest.revision, hash.clone())) {
233 return Ok(());
234 }
235
236 let mut native_keys = std::collections::HashMap::new();
237 {
238 let mut stmt = transaction
239 .prepare("SELECT path,id,native_key FROM files WHERE workspace_id=? AND deleted=0")
240 .map_err(|e| e.to_string())?;
241 let rows = stmt
242 .query_map([workspace_id], |row| {
243 Ok((
244 row.get::<_, String>(0)?,
245 row.get::<_, String>(1)?,
246 row.get::<_, String>(2)?,
247 ))
248 })
249 .map_err(|e| e.to_string())?;
250 for row in rows {
251 let (path, id, key) = row.map_err(|e| e.to_string())?;
252 native_keys.insert((path, id), key);
253 }
254 }
255 transaction
256 .execute("DELETE FROM files WHERE workspace_id=?", [workspace_id])
257 .map_err(|e| e.to_string())?;
258 transaction
259 .execute(
260 "DELETE FROM reference_ids WHERE workspace_id=?",
261 [workspace_id],
262 )
263 .map_err(|e| e.to_string())?;
264 for file in &manifest.files {
265 let key = native_keys
266 .get(&(file.path.clone(), file.id.clone()))
267 .map(String::as_str)
268 .unwrap_or("");
269 transaction.execute("INSERT INTO files(workspace_id,path,id,hash,version,deleted,native_key) VALUES(?1,?2,?3,?4,?5,?6,?7)",
270 params![workspace_id, file.path, file.id, file.hash, file.version, file.deleted, key]).map_err(|e| e.to_string())?;
271 }
272 for id in &manifest.reserved_ids {
273 transaction
274 .execute(
275 "INSERT INTO reference_ids(workspace_id,id) VALUES(?1,?2)",
276 params![workspace_id, id],
277 )
278 .map_err(|e| e.to_string())?;
279 }
280 set_cache_revision(transaction, workspace_id, manifest.revision, &hash)
281}
282
283fn from_cache(
284 transaction: &Transaction<'_>,
285 workspace_id: &str,
286 revision: u64,
287) -> Result<IdentityManifest, String> {
288 let files = {
289 let mut statement = transaction
290 .prepare(
291 "SELECT path,id,hash,version,deleted FROM files WHERE workspace_id=? ORDER BY path",
292 )
293 .map_err(|e| e.to_string())?;
294 let rows = statement
295 .query_map([workspace_id], |row| {
296 Ok(IdentityFile {
297 path: row.get(0)?,
298 id: row.get(1)?,
299 hash: row.get(2)?,
300 version: row.get(3)?,
301 deleted: row.get(4)?,
302 })
303 })
304 .map_err(|e| e.to_string())?;
305 rows.collect::<Result<_, _>>().map_err(|e| e.to_string())?
306 };
307 let reserved_ids = {
308 let mut statement = transaction
309 .prepare("SELECT id FROM reference_ids WHERE workspace_id=? ORDER BY id")
310 .map_err(|e| e.to_string())?;
311 let rows = statement
312 .query_map([workspace_id], |row| row.get(0))
313 .map_err(|e| e.to_string())?;
314 rows.collect::<Result<Vec<String>, _>>()
315 .map_err(|e| e.to_string())?
316 .into_iter()
317 .filter(|id| storage::is_short_id(id))
318 .collect()
319 };
320 Ok(IdentityManifest {
321 format: 1,
322 workspace_id: workspace_id.into(),
323 revision,
324 files,
325 reserved_ids,
326 })
327}
328
329pub fn publish(
330 transaction: &Transaction<'_>,
331 root: &Path,
332 workspace_id: &str,
333) -> Result<(), String> {
334 if !active(root, workspace_id)? {
335 return Ok(());
336 }
337 let old = read(root, workspace_id)?;
338 let revision = old.as_ref().map_or(1, |m| m.revision + 1);
339 let proposed = from_cache(transaction, workspace_id, revision)?;
340 if let Some(previous) = &old {
341 if previous.files == proposed.files && previous.reserved_ids == proposed.reserved_ids {
342 return set_cache_revision(
343 transaction,
344 workspace_id,
345 previous.revision,
346 &storage::hash(&bytes(previous)?),
347 );
348 }
349 }
350 validate(&proposed, workspace_id)?;
351 let target = path(root);
352 let new_bytes = bytes(&proposed)?;
353 if let Some(previous) = old {
354 let current = read(root, workspace_id)?.ok_or("身份清单被外部删除")?;
355 if current != previous {
356 return Err("身份清单已被外部修改,已保留双方版本".into());
357 }
358 storage::temporary(&target, &new_bytes)?
359 .persist(&target)
360 .map_err(|e| e.to_string())?;
361 } else {
362 storage::create_atomic(&target, &new_bytes)?;
363 }
364 set_cache_revision(
365 transaction,
366 workspace_id,
367 revision,
368 &storage::hash(&new_bytes),
369 )
370}
371
372pub fn publish_cache(root: &Path, workspace_id: &str, cache: &Path) -> Result<(), String> {
373 let mut connection = storage::database(cache)?;
374 let transaction = connection.transaction().map_err(|e| e.to_string())?;
375 cache_revision(&transaction, workspace_id)?;
377 publish(&transaction, root, workspace_id)?;
378 transaction.commit().map_err(|e| e.to_string())
379}
380
381pub fn reserve(
382 root: &Path,
383 workspace_id: &str,
384 cache: &Path,
385 occupied: &[String],
386) -> Result<String, String> {
387 let mut connection = storage::database(cache)?;
388 let transaction = connection
389 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
390 .map_err(|e| e.to_string())?;
391 hydrate(&transaction, root, workspace_id)?;
392 for id in occupied {
393 if storage::is_short_id(id) {
394 storage::claim_reference_id(&transaction, workspace_id, id)?;
395 }
396 }
397 let id = storage::allocate_reference_id(&transaction, workspace_id)?;
398 publish(&transaction, root, workspace_id)?;
399 transaction.commit().map_err(|e| e.to_string())?;
400 Ok(id)
401}
402
403pub fn claim_external(
404 root: &Path,
405 workspace_id: &str,
406 cache: &Path,
407 id: &str,
408) -> Result<(), String> {
409 if !storage::is_short_id(id) {
410 return Err("对象身份无效".into());
411 }
412 let mut connection = storage::database(cache)?;
413 let transaction = connection
414 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
415 .map_err(|e| e.to_string())?;
416 hydrate(&transaction, root, workspace_id)?;
417 if !storage::claim_reference_id(&transaction, workspace_id, id)? {
418 return Err("对象 ID 已被使用或预留,请重新生成建议".into());
419 }
420 publish(&transaction, root, workspace_id)?;
421 transaction.commit().map_err(|e| e.to_string())
422}
423
424#[derive(Serialize, Deserialize)]
425#[serde(rename_all = "camelCase")]
426struct PendingMove {
427 root: String,
428 workspace_id: String,
429 from: String,
430 to: String,
431}
432
433fn journal_path(cache: &Path, workspace_id: &str) -> Result<std::path::PathBuf, String> {
434 uuid::Uuid::parse_str(workspace_id).map_err(|_| "无效的工作区身份")?;
435 let directory = cache
436 .parent()
437 .ok_or("无效应用数据路径")?
438 .join("identity-operations");
439 fs::create_dir_all(&directory).map_err(|e| e.to_string())?;
440 Ok(directory.join(format!("{workspace_id}.json")))
441}
442
443pub fn begin_move(
444 root: &Path,
445 workspace_id: &str,
446 cache: &Path,
447 from: &str,
448 to: &str,
449) -> Result<(), String> {
450 if !active(root, workspace_id)? {
451 return Ok(());
452 }
453 require(root, workspace_id)?;
454 storage::validate_relative_path(from, storage::is_workspace_data_path(from))?;
455 storage::validate_relative_path(to, storage::is_workspace_data_path(to))?;
456 let journal = journal_path(cache, workspace_id)?;
457 if journal.exists() || mutation_path(cache, workspace_id)?.exists() {
458 return Err("上一次文件移动尚未恢复,请先刷新工作区".into());
459 }
460 let bytes = serde_json::to_vec(&PendingMove {
461 root: root.to_string_lossy().into_owned(),
462 workspace_id: workspace_id.into(),
463 from: from.into(),
464 to: to.into(),
465 })
466 .map_err(|e| e.to_string())?;
467 storage::create_atomic(&journal, &bytes)
468}
469
470pub fn recover_move(root: &Path, workspace_id: &str, cache: &Path) -> Result<(), String> {
471 if !active(root, workspace_id)? {
472 return Ok(());
473 }
474 let journal = journal_path(cache, workspace_id)?;
475 let Ok(metadata) = fs::symlink_metadata(&journal) else {
476 return Ok(());
477 };
478 if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 8 * 1024 {
479 return Err("文件身份操作日志无效,原文件已保留".into());
480 }
481 let pending: PendingMove =
482 serde_json::from_slice(&fs::read(&journal).map_err(|e| e.to_string())?)
483 .map_err(|e| format!("文件身份操作日志损坏:{e}"))?;
484 if pending.root != root.to_string_lossy()
485 || pending.workspace_id != workspace_id
486 || pending.from == pending.to
487 || pending.to.starts_with(&format!("{}/", pending.from))
488 {
489 return Err("文件身份操作日志与当前工作区不匹配".into());
490 }
491 storage::validate_relative_path(
492 &pending.from,
493 storage::is_workspace_data_path(&pending.from),
494 )?;
495 storage::validate_relative_path(&pending.to, storage::is_workspace_data_path(&pending.to))?;
496 let source = root.join(&pending.from);
497 let destination = root.join(&pending.to);
498 let source_exists = fs::symlink_metadata(&source).is_ok();
499 let destination_exists = fs::symlink_metadata(&destination).is_ok();
500 match (source_exists, destination_exists) {
501 (true, false) => {
502 fs::remove_file(journal).map_err(|e| e.to_string())?;
503 Ok(())
504 }
505 (false, true) => {
506 let mut manifest = read(root, workspace_id)?.ok_or("移动中身份清单缺失")?;
507 let mut changed = false;
508 for file in &mut manifest.files {
509 if file.deleted {
510 continue;
511 }
512 if file.path == pending.from || file.path.starts_with(&format!("{}/", pending.from))
513 {
514 file.path = format!("{}{}", pending.to, &file.path[pending.from.len()..]);
515 changed = true;
516 }
517 }
518 if changed {
519 manifest.files.sort_by(|a, b| a.path.cmp(&b.path));
520 manifest.revision += 1;
521 validate(&manifest, workspace_id)?;
522 let target = path(root);
523 storage::temporary(&target, &bytes(&manifest)?)?
524 .persist(&target)
525 .map_err(|e| e.to_string())?;
526 }
527 fs::remove_file(journal).map_err(|e| e.to_string())?;
528 Ok(())
529 }
530 _ => Err("移动中源和目标状态不明确,已保留原文件及操作日志,请手动处理".into()),
531 }
532}
533
534#[derive(Serialize, Deserialize)]
535#[serde(rename_all = "camelCase")]
536struct PendingMutation {
537 root: String,
538 workspace_id: String,
539 kind: String,
540 path: String,
541 id: Option<String>,
542 fingerprint: String,
543 #[serde(default)]
544 content_hash: Option<String>,
545 manifest_hash: String,
546 #[serde(default)]
547 completed_hash: Option<String>,
548}
549
550fn mutation_path(cache: &Path, workspace_id: &str) -> Result<std::path::PathBuf, String> {
551 uuid::Uuid::parse_str(workspace_id).map_err(|_| "无效的工作区身份")?;
552 let directory = cache
553 .parent()
554 .ok_or("无效应用数据路径")?
555 .join("identity-operations");
556 fs::create_dir_all(&directory).map_err(|e| e.to_string())?;
557 Ok(directory.join(format!("{workspace_id}-mutation.json")))
558}
559
560fn file_fingerprint(content: &[u8]) -> String {
561 let mut hash = Sha256::new();
562 hash.update(b"F");
563 hash.update(content);
564 format!("{:x}", hash.finalize())
565}
566
567fn fingerprint(root: &Path, target: &Path, count: &mut usize) -> Result<Option<String>, String> {
568 let metadata = match fs::symlink_metadata(target) {
569 Ok(metadata) => metadata,
570 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
571 Err(error) => return Err(error.to_string()),
572 };
573 if metadata.file_type().is_symlink()
574 || (!metadata.is_file() && !metadata.is_dir())
575 || !fs::canonicalize(target)
576 .map_err(|e| e.to_string())?
577 .starts_with(root)
578 {
579 return Err("身份操作遇到符号链接或工作区外路径".into());
580 }
581 *count += 1;
582 if *count > 20_000 {
583 return Err("身份操作包含过多文件".into());
584 }
585 let mut hash = Sha256::new();
586 if metadata.is_file() {
587 hash.update(b"F");
588 let mut file = fs::File::open(target).map_err(|e| e.to_string())?;
589 let mut buffer = [0_u8; 64 * 1024];
590 loop {
591 let size = file.read(&mut buffer).map_err(|e| e.to_string())?;
592 if size == 0 {
593 break;
594 }
595 hash.update(&buffer[..size]);
596 }
597 } else {
598 hash.update(b"D");
599 let mut children = fs::read_dir(target)
600 .map_err(|e| e.to_string())?
601 .map(|entry| entry.map_err(|e| e.to_string()))
602 .collect::<Result<Vec<_>, _>>()?;
603 children.sort_by_key(|entry| entry.file_name());
604 for child in children {
605 let name = child
606 .file_name()
607 .into_string()
608 .map_err(|_| "身份操作遇到无法表示的文件名")?;
609 let digest =
610 fingerprint(root, &child.path(), count)?.ok_or("身份操作期间文件已改变")?;
611 hash.update((name.len() as u64).to_le_bytes());
612 hash.update(name.as_bytes());
613 hash.update(digest.as_bytes());
614 }
615 }
616 Ok(Some(format!("{:x}", hash.finalize())))
617}
618
619fn start_mutation(
620 workspace_id: &str,
621 cache: &Path,
622 pending: PendingMutation,
623) -> Result<(), String> {
624 let journal = mutation_path(cache, workspace_id)?;
625 if journal.exists() || journal_path(cache, workspace_id)?.exists() {
626 return Err("上一次文件身份操作尚未完成,请先刷新工作区".into());
627 }
628 storage::create_atomic(
629 &journal,
630 &serde_json::to_vec(&pending).map_err(|e| e.to_string())?,
631 )
632}
633
634pub fn begin_create(
635 root: &Path,
636 workspace_id: &str,
637 cache: &Path,
638 relative: &str,
639 content: &[u8],
640) -> Result<(), String> {
641 require(root, workspace_id)?;
642 storage::validate_relative_path(relative, storage::is_workspace_data_path(relative))?;
643 if fs::symlink_metadata(root.join(relative)).is_ok() {
644 return Err("目标文件已存在".into());
645 }
646 if mutation_path(cache, workspace_id)?.exists() || journal_path(cache, workspace_id)?.exists() {
647 return Err("上一次文件身份操作尚未完成".into());
648 }
649 let id = reserve(root, workspace_id, cache, &[])?;
650 let manifest = read(root, workspace_id)?.ok_or("工作区身份清单缺失")?;
651 start_mutation(
652 workspace_id,
653 cache,
654 PendingMutation {
655 root: root.to_string_lossy().into_owned(),
656 workspace_id: workspace_id.into(),
657 kind: "create".into(),
658 path: relative.into(),
659 id: Some(id),
660 fingerprint: file_fingerprint(content),
661 content_hash: Some(storage::hash(content)),
662 manifest_hash: storage::hash(&bytes(&manifest)?),
663 completed_hash: None,
664 },
665 )
666}
667
668pub fn begin_delete(
669 root: &Path,
670 workspace_id: &str,
671 cache: &Path,
672 relative: &str,
673) -> Result<(), String> {
674 require(root, workspace_id)?;
675 storage::validate_relative_path(relative, storage::is_workspace_data_path(relative))?;
676 let current = fingerprint(root, &root.join(relative), &mut 0)?.ok_or("删除目标已不存在")?;
677 let manifest = read(root, workspace_id)?.ok_or("工作区身份清单缺失")?;
678 start_mutation(
679 workspace_id,
680 cache,
681 PendingMutation {
682 root: root.to_string_lossy().into_owned(),
683 workspace_id: workspace_id.into(),
684 kind: "delete".into(),
685 path: relative.into(),
686 id: None,
687 fingerprint: current,
688 content_hash: None,
689 manifest_hash: storage::hash(&bytes(&manifest)?),
690 completed_hash: None,
691 },
692 )
693}
694
695pub fn recover_mutation(root: &Path, workspace_id: &str, cache: &Path) -> Result<(), String> {
696 if !active(root, workspace_id)? {
697 return Ok(());
698 }
699 let journal = mutation_path(cache, workspace_id)?;
700 let metadata = match fs::symlink_metadata(&journal) {
701 Ok(metadata) => metadata,
702 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
703 Err(error) => return Err(error.to_string()),
704 };
705 if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 8 * 1024 {
706 return Err("文件身份操作日志无效,原文件已保留".into());
707 }
708 let mut pending: PendingMutation =
709 serde_json::from_slice(&fs::read(&journal).map_err(|e| e.to_string())?)
710 .map_err(|e| format!("文件身份操作日志损坏:{e}"))?;
711 if pending.root != root.to_string_lossy()
712 || pending.workspace_id != workspace_id
713 || !matches!(pending.kind.as_str(), "create" | "delete")
714 {
715 return Err("文件身份操作日志与当前工作区不匹配".into());
716 }
717 storage::validate_relative_path(
718 &pending.path,
719 storage::is_workspace_data_path(&pending.path),
720 )?;
721 let mut manifest = read(root, workspace_id)?.ok_or("文件身份操作期间清单丢失")?;
722 let current_hash = storage::hash(&bytes(&manifest)?);
723 if pending.completed_hash.as_deref() == Some(current_hash.as_str()) {
724 return fs::remove_file(journal).map_err(|e| e.to_string());
725 }
726 if current_hash != pending.manifest_hash {
727 return Err("身份清单在文件操作期间被外部修改,已保留操作日志".into());
728 }
729 let current = fingerprint(root, &root.join(&pending.path), &mut 0)?;
730 let changed = match pending.kind.as_str() {
731 "create" => match current {
732 None => false,
733 Some(value) if value == pending.fingerprint => {
734 let id = pending
735 .id
736 .as_deref()
737 .filter(|id| {
738 storage::is_short_id(id)
739 && manifest.reserved_ids.iter().any(|reserved| reserved == *id)
740 })
741 .ok_or("创建操作中的短 ID 无效")?;
742 let content_hash = pending
743 .content_hash
744 .as_deref()
745 .filter(|hash| {
746 hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
747 })
748 .ok_or("创建操作中的内容 hash 无效")?;
749 manifest.files.retain(|file| file.path != pending.path);
750 manifest.files.push(IdentityFile {
751 path: pending.path.clone(),
752 id: id.into(),
753 hash: content_hash.into(),
754 version: 1,
755 deleted: false,
756 });
757 true
758 }
759 _ => return Err("创建期间目标文件被外部修改,已保留操作日志".into()),
760 },
761 "delete" => match current {
762 None => {
763 let prefix = format!("{}/", pending.path);
764 for file in &mut manifest.files {
765 if file.path == pending.path || file.path.starts_with(&prefix) {
766 file.deleted = true;
767 }
768 }
769 true
770 }
771 Some(value) if value == pending.fingerprint => false,
772 _ => return Err("删除期间目标文件被外部修改,已保留操作日志".into()),
773 },
774 _ => unreachable!(),
775 };
776 if changed {
777 manifest.revision += 1;
778 manifest.files.sort_by(|a, b| a.path.cmp(&b.path));
779 validate(&manifest, workspace_id)?;
780 let proposed = bytes(&manifest)?;
781 let completed_hash = storage::hash(&proposed);
782 let before = read(root, workspace_id)?.ok_or("身份清单被外部删除")?;
783 if storage::hash(&bytes(&before)?) != pending.manifest_hash {
784 return Err("身份清单已被外部修改,已保留操作日志".into());
785 }
786 pending.completed_hash = Some(completed_hash);
787 storage::temporary(
788 &journal,
789 &serde_json::to_vec(&pending).map_err(|e| e.to_string())?,
790 )?
791 .persist(&journal)
792 .map_err(|e| e.to_string())?;
793 storage::temporary(&path(root), &proposed)?
794 .persist(path(root))
795 .map_err(|e| e.to_string())?;
796 }
797 fs::remove_file(journal).map_err(|e| e.to_string())
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803
804 #[test]
805 fn hydration_cache_requires_both_file_stamp_and_database_revision() {
806 let (_directory, _data, root, id, cache) = workspace();
807 storage::scan(&root, &id, &cache).unwrap();
808 let mut connection = storage::database(&cache).unwrap();
809 let transaction = connection.transaction().unwrap();
810 hydrate(&transaction, &root, &id).unwrap();
811 MANIFEST_READS.with(|count| count.set(0));
812 hydrate(&transaction, &root, &id).unwrap();
813 if !storage::scan_cache::state(&path(&root)).stamp.is_empty() {
814 assert_eq!(MANIFEST_READS.with(|count| count.get()), 0);
815 }
816 transaction
817 .execute("DELETE FROM identity_revisions WHERE workspace_id=?", [&id])
818 .unwrap();
819 MANIFEST_READS.with(|count| count.set(0));
820 hydrate(&transaction, &root, &id).unwrap();
821 assert_eq!(MANIFEST_READS.with(|count| count.get()), 1);
822 let file = path(&root);
823 let original = fs::read_to_string(&file).unwrap();
824 let modified = fs::metadata(&file).unwrap().modified().unwrap();
825 let invalid = original.replacen("\"format\": 1", "\"format\": 9", 1);
826 fs::write(&file, &invalid).unwrap();
827 fs::File::options()
828 .write(true)
829 .open(&file)
830 .unwrap()
831 .set_times(fs::FileTimes::new().set_modified(modified))
832 .unwrap();
833 assert!(hydrate(&transaction, &root, &id).is_err());
834 fs::write(&file, &original).unwrap();
835 hydrate(&transaction, &root, &id).unwrap();
836 storage::temporary(&file, invalid.as_bytes())
837 .unwrap()
838 .persist(&file)
839 .unwrap();
840 assert!(hydrate(&transaction, &root, &id).is_err());
841 fs::write(&file, &original).unwrap();
842 hydrate(&transaction, &root, &id).unwrap();
843 fs::remove_file(&file).unwrap();
844 assert!(hydrate(&transaction, &root, &id).is_err());
845 }
846
847 #[test]
848 fn validation_cache_skips_unchanged_reads_but_detects_external_changes() {
849 let (_directory, _data, root, id, cache) = workspace();
850 storage::scan(&root, &id, &cache).unwrap();
851 require(&root, &id).unwrap();
852 MANIFEST_READS.with(|count| count.set(0));
853 for _ in 0..20 {
854 require(&root, &id).unwrap();
855 }
856 if !storage::scan_cache::state(&path(&root)).stamp.is_empty() {
857 assert_eq!(MANIFEST_READS.with(|count| count.get()), 0);
858 }
859 let file = path(&root);
860 let original = fs::read_to_string(&file).unwrap();
861 let invalid = original.replacen("\"format\": 1", "\"format\": 9", 1);
862 assert_ne!(original, invalid);
863 let modified = fs::metadata(&file).unwrap().modified().unwrap();
864 fs::write(&file, &invalid).unwrap();
865 fs::File::options()
866 .write(true)
867 .open(&file)
868 .unwrap()
869 .set_times(fs::FileTimes::new().set_modified(modified))
870 .unwrap();
871 assert!(
872 require(&root, &id).is_err(),
873 "same-size, restored-mtime edit"
874 );
875 fs::write(&file, &original).unwrap();
876 require(&root, &id).unwrap();
877 storage::temporary(&file, invalid.as_bytes())
878 .unwrap()
879 .persist(&file)
880 .unwrap();
881 assert!(require(&root, &id).is_err(), "atomic replacement");
882 fs::write(&file, &original).unwrap();
883 require(&root, &id).unwrap();
884 assert!(require(&root, &uuid::Uuid::new_v4().to_string()).is_err());
885 fs::remove_file(&file).unwrap();
886 assert!(require(&root, &id).is_err(), "deleted manifest");
887 fs::write(&file, original).unwrap();
888 require(&root, &id).unwrap();
889 fs::remove_file(root.join(".tano/config.json")).unwrap();
890 assert!(
891 require(&root, &id).is_err(),
892 "identity is checked even on a cache hit"
893 );
894 }
895
896 #[cfg(windows)]
897 #[test]
898 fn validation_cache_is_disabled_while_a_writer_is_open() {
899 use std::io::{Seek, SeekFrom, Write};
900 let (_directory, _data, root, id, cache) = workspace();
901 storage::scan(&root, &id, &cache).unwrap();
902 require(&root, &id).unwrap();
903 let file = path(&root);
904 let original = fs::read_to_string(&file).unwrap();
905 let invalid = original.replacen("\"format\": 1", "\"format\": 9", 1);
906 let mut writer = fs::File::options().write(true).open(&file).unwrap();
907 assert!(storage::scan_cache::state(&file).stamp.is_empty());
908 writer.write_all(invalid.as_bytes()).unwrap();
909 writer.flush().unwrap();
910 assert!(require(&root, &id).is_err());
911 writer.seek(SeekFrom::Start(0)).unwrap();
912 writer.write_all(original.as_bytes()).unwrap();
913 writer.flush().unwrap();
914 MANIFEST_READS.with(|count| count.set(0));
915 require(&root, &id).unwrap();
916 require(&root, &id).unwrap();
917 assert_eq!(MANIFEST_READS.with(|count| count.get()), 2);
918 let mut connection = storage::database(&cache).unwrap();
919 let transaction = connection.transaction().unwrap();
920 hydrate(&transaction, &root, &id).unwrap();
921 MANIFEST_READS.with(|count| count.set(0));
922 hydrate(&transaction, &root, &id).unwrap();
923 hydrate(&transaction, &root, &id).unwrap();
924 assert_eq!(MANIFEST_READS.with(|count| count.get()), 2);
925 drop(writer);
926 require(&root, &id).unwrap();
927 }
928
929 fn workspace() -> (
930 tempfile::TempDir,
931 tempfile::TempDir,
932 std::path::PathBuf,
933 String,
934 std::path::PathBuf,
935 ) {
936 let directory = tempfile::tempdir().unwrap();
937 let data = tempfile::tempdir().unwrap();
938 let root = fs::canonicalize(directory.path()).unwrap();
939 let id = storage::workspace_identity(&root).unwrap();
940 let cache = data.path().join("index.sqlite");
941 (directory, data, root, id, cache)
942 }
943
944 #[test]
945 fn external_identity_claims_never_reuse_reserved_ids_even_after_index_loss() {
946 let (_directory, _data, root, id, cache) = workspace();
947 storage::scan(&root, &id, &cache).unwrap();
948 claim_external(&root, &id, &cache, "f12345").unwrap();
949 assert!(claim_external(&root, &id, &cache, "f12345").is_err());
950 let fresh_cache = cache.with_file_name("fresh.sqlite");
951 assert!(claim_external(&root, &id, &fresh_cache, "f12345").is_err());
952 claim_external(&root, &id, &fresh_cache, "f12346").unwrap();
953 }
954
955 #[test]
956 fn portable_manifest_recovers_file_and_object_ids_after_index_loss() {
957 let (_directory, _data, root, workspace_id, cache) = workspace();
958 fs::write(root.join("note.md"), "# [[person:a82f91]]").unwrap();
959 fs::create_dir(root.join(".tano/objects")).unwrap();
960 fs::write(root.join(".tano/objects/person.tano.json"),
961 r#"{"version":1,"id":"a82f91","type":"person","name":"Alice","description":"","archived":false,"properties":{}}"#).unwrap();
962 fs::write(
963 root.join(".tano/relations.tano-relations.json"),
964 r#"{"version":1,"relations":[]}"#,
965 )
966 .unwrap();
967 let before = storage::scan(&root, &workspace_id, &cache).unwrap();
968 let identities: Vec<_> = before
969 .files
970 .iter()
971 .map(|file| (file.path.clone(), file.id.clone()))
972 .collect();
973 let manifest = read(&root, &workspace_id).unwrap().unwrap();
974 assert!(manifest.reserved_ids.contains(&"a82f91".to_string()));
975 assert_eq!(manifest.files.len(), 3);
976 fs::remove_file(&cache).unwrap();
977 let after = storage::scan(&root, &workspace_id, &cache).unwrap();
978 assert_eq!(
979 after
980 .files
981 .iter()
982 .map(|file| (file.path.clone(), file.id.clone()))
983 .collect::<Vec<_>>(),
984 identities
985 );
986 assert_eq!(
987 fs::read_to_string(root.join("note.md")).unwrap(),
988 "# [[person:a82f91]]"
989 );
990 assert_eq!(
991 storage::reserve_reference_id(&cache, &workspace_id, &["a82f91".into()])
992 .unwrap()
993 .len(),
994 6
995 );
996 }
997
998 #[test]
999 fn interrupted_move_recovers_identity_with_duplicate_content_and_empty_cache() {
1000 let (_directory, _data, root, workspace_id, cache) = workspace();
1001 fs::write(root.join("one.md"), "same bytes").unwrap();
1002 fs::write(root.join("two.md"), "same bytes").unwrap();
1003 let before = storage::scan(&root, &workspace_id, &cache).unwrap();
1004 let original = before
1005 .files
1006 .iter()
1007 .find(|file| file.path == "one.md")
1008 .unwrap()
1009 .id
1010 .clone();
1011 begin_move(&root, &workspace_id, &cache, "one.md", "moved.md").unwrap();
1012 fs::rename(root.join("one.md"), root.join("moved.md")).unwrap();
1013 fs::remove_file(&cache).unwrap();
1014 let after = storage::scan(&root, &workspace_id, &cache).unwrap();
1015 assert_eq!(
1016 after
1017 .files
1018 .iter()
1019 .find(|file| file.path == "moved.md")
1020 .unwrap()
1021 .id,
1022 original
1023 );
1024 assert_eq!(
1025 after
1026 .files
1027 .iter()
1028 .find(|file| file.path == "two.md")
1029 .unwrap()
1030 .id,
1031 before
1032 .files
1033 .iter()
1034 .find(|file| file.path == "two.md")
1035 .unwrap()
1036 .id
1037 );
1038 assert_eq!(
1039 read(&root, &workspace_id)
1040 .unwrap()
1041 .unwrap()
1042 .files
1043 .iter()
1044 .find(|file| file.path == "moved.md")
1045 .unwrap()
1046 .id,
1047 original
1048 );
1049 }
1050
1051 #[test]
1052 fn invalid_manifest_is_preserved_and_missing_manifest_is_not_recreated() {
1053 let (_directory, _data, root, workspace_id, cache) = workspace();
1054 fs::write(root.join("note.md"), "text").unwrap();
1055 storage::scan(&root, &workspace_id, &cache).unwrap();
1056 let file = path(&root);
1057 fs::write(&file, "broken JSON").unwrap();
1058 assert!(storage::scan(&root, &workspace_id, &cache).is_err());
1059 assert_eq!(fs::read_to_string(&file).unwrap(), "broken JSON");
1060 fs::remove_file(&file).unwrap();
1061 assert!(storage::scan(&root, &workspace_id, &cache).is_err());
1062 assert!(!file.exists());
1063 }
1064
1065 #[test]
1066 fn interrupted_delete_and_create_at_same_path_get_distinct_ids_without_index() {
1067 let (_directory, _data, root, workspace_id, cache) = workspace();
1068 fs::write(root.join("note.md"), "original").unwrap();
1069 let old = storage::scan(&root, &workspace_id, &cache).unwrap().files[0]
1070 .id
1071 .clone();
1072 begin_delete(&root, &workspace_id, &cache, "note.md").unwrap();
1073 fs::remove_file(root.join("note.md")).unwrap();
1074 fs::remove_file(&cache).unwrap();
1075 storage::scan(&root, &workspace_id, &cache).unwrap();
1076 assert!(read(&root, &workspace_id)
1077 .unwrap()
1078 .unwrap()
1079 .files
1080 .iter()
1081 .any(|file| file.id == old && file.deleted));
1082
1083 begin_create(&root, &workspace_id, &cache, "note.md", b"replacement").unwrap();
1084 fs::write(root.join("note.md"), "replacement").unwrap();
1085 fs::remove_file(&cache).unwrap();
1086 let reopened = storage::scan(&root, &workspace_id, &cache).unwrap();
1087 let new_id = &reopened.files[0].id;
1088 assert_ne!(new_id, &old);
1089 let manifest = read(&root, &workspace_id).unwrap().unwrap();
1090 assert!(manifest.reserved_ids.contains(&old));
1091 assert!(manifest.reserved_ids.contains(new_id));
1092 assert_eq!(
1093 fs::read_to_string(root.join("note.md")).unwrap(),
1094 "replacement"
1095 );
1096 }
1097
1098 #[test]
1099 fn mutation_recovery_preserves_external_changes_and_replays_completed_manifest() {
1100 let (_directory, _data, root, workspace_id, cache) = workspace();
1101 storage::scan(&root, &workspace_id, &cache).unwrap();
1102 begin_create(&root, &workspace_id, &cache, "note.md", b"expected").unwrap();
1103 fs::write(root.join("note.md"), "external").unwrap();
1104 assert!(storage::scan(&root, &workspace_id, &cache)
1105 .err()
1106 .unwrap()
1107 .contains("外部修改"));
1108 assert_eq!(
1109 fs::read_to_string(root.join("note.md")).unwrap(),
1110 "external"
1111 );
1112 assert!(mutation_path(&cache, &workspace_id).unwrap().exists());
1113
1114 fs::write(root.join("note.md"), "expected").unwrap();
1115 let journal = mutation_path(&cache, &workspace_id).unwrap();
1116 let mut pending: PendingMutation =
1117 serde_json::from_slice(&fs::read(&journal).unwrap()).unwrap();
1118 let mut manifest = read(&root, &workspace_id).unwrap().unwrap();
1119 manifest.files.push(IdentityFile {
1120 path: "note.md".into(),
1121 id: pending.id.clone().unwrap(),
1122 hash: pending.content_hash.clone().unwrap(),
1123 version: 1,
1124 deleted: false,
1125 });
1126 manifest.revision += 1;
1127 let completed = bytes(&manifest).unwrap();
1128 pending.completed_hash = Some(storage::hash(&completed));
1129 fs::write(&journal, serde_json::to_vec(&pending).unwrap()).unwrap();
1130 fs::write(path(&root), completed).unwrap();
1131 let opened = storage::scan(&root, &workspace_id, &cache).unwrap();
1132 assert_eq!(opened.files[0].id, pending.id.unwrap());
1133 assert!(!journal.exists());
1134 }
1135}