Skip to main content

tano_native/
identity_manifest.rs

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
22// Validation and the hydrated revision are memoized. Mutations still read,
23// compare and publish the manifest directly.
24// Bound the process-local hints across windows/workspaces. A reliable stamp
25// includes native identity and change time, not just mtime and length.
26static 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    // A writer or an unreliable stamp requires a fresh read on the next call.
89    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    // Reinitialization can publish an empty manifest before the first scan.
376    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}