1use 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";
17const 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 #[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 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 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
295fn 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 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}