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