Skip to main content

fur_cli/schema/
snapshot.rs

1//! Registry-neutral snapshots of canonical FUR conversation directories.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Component, Path, PathBuf};
6
7use base64::engine::general_purpose::STANDARD as BASE64;
8use base64::Engine as _;
9use serde::{Deserialize, Serialize};
10use serde_json::{json, Value};
11use sha2::{Digest, Sha256};
12
13use crate::schema::document::{parse, FurDocument};
14use crate::schema::rebuild::spine_paths;
15
16const ORIGIN_FILENAME: &str = ".fur-origin.json";
17/// Where this copy was last published, written beside the document so it
18/// survives a `.fur/` rebuild. Excluded from the snapshot like the origin
19/// receipt: it describes the record's history, it is not the record.
20const PUBLISHED_FILENAME: &str = ".fur-published.json";
21
22#[derive(Debug, Clone, Serialize)]
23pub struct PublishIntent {
24    pub publish_schema: &'static str,
25    pub source: Value,
26    pub snapshot: Snapshot,
27    /// The conversation folder, so the caller can write a receipt next to it.
28    #[serde(skip)]
29    pub folder: PathBuf,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PublishedReceipt {
34    pub registry_url: String,
35    pub registry_id: String,
36    pub publication_id: String,
37    pub revision_id: String,
38    pub snapshot_digest: String,
39    pub published_at: String,
40}
41
42pub fn write_published_receipt(folder: &Path, receipt: &PublishedReceipt) -> Result<(), String> {
43    let path = folder.join(PUBLISHED_FILENAME);
44    let text = serde_json::to_string_pretty(receipt)
45        .map_err(|e| format!("cannot serialize publish receipt: {}", e))?;
46    fs::write(&path, format!("{}\n", text))
47        .map_err(|e| format!("cannot write {}: {}", path.display(), e))
48}
49
50#[derive(Debug, Clone, Serialize)]
51pub struct Snapshot {
52    pub digest_algorithm: &'static str,
53    pub digest: String,
54    pub spine_path: String,
55    pub files: Vec<SnapshotFile>,
56}
57
58#[derive(Debug, Clone, Serialize)]
59pub struct SnapshotFile {
60    pub path: String,
61    pub media_type: String,
62    pub size: u64,
63    pub sha256: String,
64    pub encoding: &'static str,
65    pub content: String,
66}
67
68#[derive(Debug, Deserialize)]
69struct OriginReceipt {
70    registry_id: String,
71    publication_id: String,
72    revision_id: String,
73    source_conversation_id: String,
74    snapshot_digest: String,
75}
76
77pub fn build_publish_intent(root: &Path, selector: Option<&str>) -> Result<PublishIntent, String> {
78    let (folder, spine, doc) = resolve_conversation(root, selector)?;
79    let mut files = collect_snapshot_files(&folder)?;
80    files.sort_by(|left, right| left.path.cmp(&right.path));
81
82    let spine_path = relative_path(&folder, &spine)?;
83    validate_document_files(&doc, &files)?;
84    let digest = manifest_digest(&files);
85    let source = source_metadata(&folder, &doc)?;
86
87    Ok(PublishIntent {
88        publish_schema: "fur.registry.publish.v1",
89        source,
90        snapshot: Snapshot {
91            digest_algorithm: "sha256-manifest-v1",
92            digest,
93            spine_path,
94            files,
95        },
96        folder,
97    })
98}
99
100fn validate_document_files(doc: &FurDocument, files: &[SnapshotFile]) -> Result<(), String> {
101    let hashes: BTreeMap<&str, &str> = files
102        .iter()
103        .map(|file| (file.path.as_str(), file.sha256.as_str()))
104        .collect();
105
106    for message in &doc.messages {
107        for reference in [&message.link, &message.img].into_iter().flatten() {
108            if !hashes.contains_key(reference.as_str()) {
109                return Err(format!("conversation references missing file: {}", reference));
110            }
111        }
112        if let (Some(link), Some(expected)) = (&message.link, &message.sha256) {
113            if hashes.get(link.as_str()).copied() != Some(expected.as_str()) {
114                return Err(format!("linked file hash does not match convo.md: {}", link));
115            }
116        }
117    }
118    Ok(())
119}
120
121fn resolve_conversation(
122    root: &Path,
123    selector: Option<&str>,
124) -> Result<(PathBuf, PathBuf, FurDocument), String> {
125    let selected = match selector {
126        Some(value) if !value.trim().is_empty() => value.trim().to_string(),
127        _ => active_conversation_id(root)?,
128    };
129
130    let mut matches = Vec::new();
131    for spine in spine_paths(root) {
132        let text = fs::read_to_string(&spine)
133            .map_err(|e| format!("cannot read {}: {}", spine.display(), e))?;
134        let doc = parse(&text).map_err(|e| format!("invalid {}: {}", spine.display(), e))?;
135        if doc.conversation_id == selected || doc.conversation_id.starts_with(&selected) {
136            let folder = spine
137                .parent()
138                .ok_or_else(|| format!("{} has no conversation folder", spine.display()))?
139                .to_path_buf();
140            matches.push((folder, spine, doc));
141        }
142    }
143
144    match matches.len() {
145        0 => Err(format!("no conversation matches '{}'", selected)),
146        1 => Ok(matches.remove(0)),
147        _ => Err(format!("conversation prefix '{}' is ambiguous", selected)),
148    }
149}
150
151fn active_conversation_id(root: &Path) -> Result<String, String> {
152    let path = root.join(".fur").join("index.json");
153    let text = fs::read_to_string(&path)
154        .map_err(|_| "no active conversation; pass a conversation hash".to_string())?;
155    let index: Value = serde_json::from_str(&text)
156        .map_err(|e| format!("cannot parse {}: {}", path.display(), e))?;
157    index["active_thread"]
158        .as_str()
159        .filter(|value| !value.is_empty())
160        .map(str::to_string)
161        .ok_or_else(|| "no active conversation; pass a conversation hash".to_string())
162}
163
164fn collect_snapshot_files(folder: &Path) -> Result<Vec<SnapshotFile>, String> {
165    let mut paths = Vec::new();
166    collect_paths(folder, &mut paths)?;
167
168    let mut files = Vec::new();
169    for path in paths {
170        let relative = relative_path(folder, &path)?;
171        if relative == ORIGIN_FILENAME || relative == PUBLISHED_FILENAME {
172            continue;
173        }
174        files.push(snapshot_file(folder, &path)?);
175    }
176    Ok(files)
177}
178
179pub(crate) fn snapshot_file(root: &Path, path: &Path) -> Result<SnapshotFile, String> {
180    let relative = relative_path(root, path)?;
181    let bytes = fs::read(path).map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
182    let (encoding, content) = match String::from_utf8(bytes.clone()) {
183        Ok(text) => ("utf-8", text),
184        Err(_) => ("base64", BASE64.encode(&bytes)),
185    };
186    Ok(SnapshotFile {
187        path: relative.clone(),
188        media_type: media_type(&relative).to_string(),
189        size: bytes.len() as u64,
190        sha256: sha256(&bytes),
191        encoding,
192        content,
193    })
194}
195
196fn collect_paths(directory: &Path, paths: &mut Vec<PathBuf>) -> Result<(), String> {
197    let entries = fs::read_dir(directory)
198        .map_err(|e| format!("cannot read {}: {}", directory.display(), e))?;
199    for entry in entries {
200        let entry = entry.map_err(|e| format!("cannot read directory entry: {}", e))?;
201        let path = entry.path();
202        let metadata = fs::symlink_metadata(&path)
203            .map_err(|e| format!("cannot inspect {}: {}", path.display(), e))?;
204        if metadata.file_type().is_symlink() {
205            return Err(format!("refusing to publish symlink: {}", path.display()));
206        }
207        if metadata.is_dir() {
208            collect_paths(&path, paths)?;
209        } else if metadata.is_file() {
210            paths.push(path);
211        }
212    }
213    Ok(())
214}
215
216fn source_metadata(folder: &Path, doc: &FurDocument) -> Result<Value, String> {
217    let origin_path = folder.join(ORIGIN_FILENAME);
218
219    // Lineage is carried in `source`, not in the snapshot, because `source` is
220    // the queryable metadata channel: a registry can index and graph these
221    // edges without parsing every spine it holds.
222    //
223    // The edges are *also* inside `convo.md`, which is part of the digest, so
224    // linking a published conversation legitimately produces a new revision.
225    // That is the intended reading — the document is the record, and the record
226    // changed.
227    //
228    // Edges name conversation ids, not publication ids. A referenced
229    // conversation that was never published leaves an edge pointing at nothing,
230    // which is partial knowledge rather than corruption, exactly as it is
231    // locally.
232    
233    // Authorship travels beside lineage, for the same reason: a registry can
234    // index and display who spoke without parsing every spine it holds.
235    //
236    // Derived from the `avatar=` markers actually present in the document —
237    // ground truth for who spoke — not from whoever ran `fur publish`. Humans
238    // and AI share one list, discriminated by `kind`, because the document
239    // format makes no distinction and consumers should choose presentation.
240    // `role` appears only when configured.
241    //
242    // `source` is not hashed, so enriching authorship never forces a revision.
243    let source = json!({
244        "format": "fur.conversation-directory",
245        "fur_document_schema": doc.schema,
246        "conversation_id": doc.conversation_id,
247        "origin_kind": "local",
248        "lineage": {
249            "parents": doc.parents,
250            "children": doc.children
251        },
252        "authors": document_authors(folder, doc),
253        "producer": {"fur_version": env!("CARGO_PKG_VERSION")}
254    });
255    let mut source = source;
256
257    if origin_path.exists() {
258        let text = fs::read_to_string(&origin_path)
259            .map_err(|e| format!("cannot read {}: {}", origin_path.display(), e))?;
260        let receipt: OriginReceipt = serde_json::from_str(&text)
261            .map_err(|e| format!("invalid {}: {}", origin_path.display(), e))?;
262        if receipt.source_conversation_id != doc.conversation_id {
263            return Err("origin receipt does not match this conversation".to_string());
264        }
265        source["origin_kind"] = json!("registry-import");
266        source["import_origin"] = json!({
267            "registry_id": receipt.registry_id,
268            "publication_id": receipt.publication_id,
269            "revision_id": receipt.revision_id,
270            "source_conversation_id": receipt.source_conversation_id,
271            "snapshot_digest": receipt.snapshot_digest
272        });
273    }
274
275    // The cursor says which revision this copy is a continuation of. A
276    // registry uses it to tell a fast-forward from a fork; the import origin
277    // above is the same claim for a copy that has never been republished.
278    let published_path = folder.join(PUBLISHED_FILENAME);
279    if published_path.exists() {
280        let text = fs::read_to_string(&published_path)
281            .map_err(|e| format!("cannot read {}: {}", published_path.display(), e))?;
282        let receipt: PublishedReceipt = serde_json::from_str(&text)
283            .map_err(|e| format!("invalid {}: {}", published_path.display(), e))?;
284        source["publish_cursor"] = json!({
285            "registry_url": receipt.registry_url,
286            "registry_id": receipt.registry_id,
287            "publication_id": receipt.publication_id,
288            "revision_id": receipt.revision_id,
289            "snapshot_digest": receipt.snapshot_digest
290        });
291    }
292    Ok(source)
293}
294
295/// The distinct avatars that speak in this document, sorted for determinism.
296///
297/// Reads `.fur/avatars.json` when present, purely to enrich; an archive with no
298/// `.fur/` still publishes correct authorship from the markers alone.
299fn document_authors(folder: &Path, doc: &FurDocument) -> Vec<Value> {
300    let avatars = folder
301        .parent()
302        .and_then(|chats| chats.parent())
303        .map(|root| root.join(".fur").join("avatars.json"))
304        .and_then(|path| fs::read_to_string(path).ok())
305        .and_then(|text| serde_json::from_str::<Value>(&text).ok())
306        .unwrap_or_else(|| json!({}));
307
308    let mut names: Vec<&str> = doc
309        .messages
310        .iter()
311        .map(|message| message.avatar.as_str())
312        .filter(|name| !name.trim().is_empty())
313        .collect();
314    names.sort_unstable();
315    names.dedup();
316    // Humans first, then AI — a paper lists its authors before its tools.
317    names.sort_by_key(|name| crate::avatars::kind_of(&avatars, name) == "ai");
318
319    names
320        .into_iter()
321        .map(|name| {
322            let mut entry = json!({
323                "name": name,
324                "kind": crate::avatars::kind_of(&avatars, name)
325            });
326            if let Some(display) = crate::avatars::display_name_of(&avatars, name) {
327                entry["display_name"] = json!(display);
328            }
329            if let Some(role) = crate::avatars::role_of(&avatars, name) {
330                entry["role"] = json!(role);
331            }
332            entry
333        })
334        .collect()
335}
336
337fn relative_path(root: &Path, path: &Path) -> Result<String, String> {
338    let relative = path
339        .strip_prefix(root)
340        .map_err(|_| format!("{} is outside {}", path.display(), root.display()))?;
341    if relative.components().any(|part| !matches!(part, Component::Normal(_))) {
342        return Err(format!("unsafe snapshot path: {}", relative.display()));
343    }
344    let parts = relative
345        .components()
346        .map(|part| {
347            part.as_os_str()
348                .to_str()
349                .ok_or_else(|| format!("snapshot path is not UTF-8: {}", relative.display()))
350        })
351        .collect::<Result<Vec<_>, _>>()?;
352    let normalized = parts.join("/");
353    if normalized.contains('\\') {
354        return Err(format!("unsafe snapshot path: {}", relative.display()));
355    }
356    Ok(normalized)
357}
358
359fn manifest_digest(files: &[SnapshotFile]) -> String {
360    let mut hasher = Sha256::new();
361    for file in files {
362        hasher.update(file.path.as_bytes());
363        hasher.update([0]);
364        hasher.update(file.size.to_string().as_bytes());
365        hasher.update([0]);
366        hasher.update(file.sha256.as_bytes());
367        hasher.update(b"\n");
368    }
369    format!("{:x}", hasher.finalize())
370}
371
372fn sha256(bytes: &[u8]) -> String {
373    let mut hasher = Sha256::new();
374    hasher.update(bytes);
375    format!("{:x}", hasher.finalize())
376}
377
378fn media_type(path: &str) -> &'static str {
379    match Path::new(path).extension().and_then(|value| value.to_str()) {
380        Some("md") => "text/markdown",
381        Some("json") => "application/json",
382        Some("png") => "image/png",
383        Some("jpg" | "jpeg") => "image/jpeg",
384        Some("gif") => "image/gif",
385        Some("webp") => "image/webp",
386        Some("svg") => "image/svg+xml",
387        Some("pdf") => "application/pdf",
388        _ => "application/octet-stream",
389    }
390}