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";
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 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
251fn 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}