use std::collections::BTreeMap;
use std::fs;
use std::path::{Component, Path, PathBuf};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use crate::schema::document::{parse, FurDocument};
use crate::schema::rebuild::spine_paths;
const ORIGIN_FILENAME: &str = ".fur-origin.json";
const PUBLISHED_FILENAME: &str = ".fur-published.json";
#[derive(Debug, Clone, Serialize)]
pub struct PublishIntent {
pub publish_schema: &'static str,
pub source: Value,
pub snapshot: Snapshot,
#[serde(skip)]
pub folder: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishedReceipt {
pub registry_url: String,
pub registry_id: String,
pub publication_id: String,
pub revision_id: String,
pub snapshot_digest: String,
pub published_at: String,
}
pub fn write_published_receipt(folder: &Path, receipt: &PublishedReceipt) -> Result<(), String> {
let path = folder.join(PUBLISHED_FILENAME);
let text = serde_json::to_string_pretty(receipt)
.map_err(|e| format!("cannot serialize publish receipt: {}", e))?;
fs::write(&path, format!("{}\n", text))
.map_err(|e| format!("cannot write {}: {}", path.display(), e))
}
#[derive(Debug, Clone, Serialize)]
pub struct Snapshot {
pub digest_algorithm: &'static str,
pub digest: String,
pub spine_path: String,
pub files: Vec<SnapshotFile>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SnapshotFile {
pub path: String,
pub media_type: String,
pub size: u64,
pub sha256: String,
pub encoding: &'static str,
pub content: String,
}
#[derive(Debug, Deserialize)]
struct OriginReceipt {
registry_id: String,
publication_id: String,
revision_id: String,
source_conversation_id: String,
snapshot_digest: String,
}
pub fn build_publish_intent(root: &Path, selector: Option<&str>) -> Result<PublishIntent, String> {
let (folder, spine, doc) = resolve_conversation(root, selector)?;
let mut files = collect_snapshot_files(&folder)?;
files.sort_by(|left, right| left.path.cmp(&right.path));
let spine_path = relative_path(&folder, &spine)?;
validate_document_files(&doc, &files)?;
let digest = manifest_digest(&files);
let source = source_metadata(&folder, &doc)?;
Ok(PublishIntent {
publish_schema: "fur.registry.publish.v1",
source,
snapshot: Snapshot {
digest_algorithm: "sha256-manifest-v1",
digest,
spine_path,
files,
},
folder,
})
}
fn validate_document_files(doc: &FurDocument, files: &[SnapshotFile]) -> Result<(), String> {
let hashes: BTreeMap<&str, &str> = files
.iter()
.map(|file| (file.path.as_str(), file.sha256.as_str()))
.collect();
for message in &doc.messages {
for reference in [&message.link, &message.img].into_iter().flatten() {
if !hashes.contains_key(reference.as_str()) {
return Err(format!("conversation references missing file: {}", reference));
}
}
if let (Some(link), Some(expected)) = (&message.link, &message.sha256) {
if hashes.get(link.as_str()).copied() != Some(expected.as_str()) {
return Err(format!("linked file hash does not match convo.md: {}", link));
}
}
}
Ok(())
}
fn resolve_conversation(
root: &Path,
selector: Option<&str>,
) -> Result<(PathBuf, PathBuf, FurDocument), String> {
let selected = match selector {
Some(value) if !value.trim().is_empty() => value.trim().to_string(),
_ => active_conversation_id(root)?,
};
let mut matches = Vec::new();
for spine in spine_paths(root) {
let text = fs::read_to_string(&spine)
.map_err(|e| format!("cannot read {}: {}", spine.display(), e))?;
let doc = parse(&text).map_err(|e| format!("invalid {}: {}", spine.display(), e))?;
if doc.conversation_id == selected || doc.conversation_id.starts_with(&selected) {
let folder = spine
.parent()
.ok_or_else(|| format!("{} has no conversation folder", spine.display()))?
.to_path_buf();
matches.push((folder, spine, doc));
}
}
match matches.len() {
0 => Err(format!("no conversation matches '{}'", selected)),
1 => Ok(matches.remove(0)),
_ => Err(format!("conversation prefix '{}' is ambiguous", selected)),
}
}
fn active_conversation_id(root: &Path) -> Result<String, String> {
let path = root.join(".fur").join("index.json");
let text = fs::read_to_string(&path)
.map_err(|_| "no active conversation; pass a conversation hash".to_string())?;
let index: Value = serde_json::from_str(&text)
.map_err(|e| format!("cannot parse {}: {}", path.display(), e))?;
index["active_thread"]
.as_str()
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| "no active conversation; pass a conversation hash".to_string())
}
fn collect_snapshot_files(folder: &Path) -> Result<Vec<SnapshotFile>, String> {
let mut paths = Vec::new();
collect_paths(folder, &mut paths)?;
let mut files = Vec::new();
for path in paths {
let relative = relative_path(folder, &path)?;
if relative == ORIGIN_FILENAME || relative == PUBLISHED_FILENAME {
continue;
}
files.push(snapshot_file(folder, &path)?);
}
Ok(files)
}
pub(crate) fn snapshot_file(root: &Path, path: &Path) -> Result<SnapshotFile, String> {
let relative = relative_path(root, path)?;
let bytes = fs::read(path).map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
let (encoding, content) = match String::from_utf8(bytes.clone()) {
Ok(text) => ("utf-8", text),
Err(_) => ("base64", BASE64.encode(&bytes)),
};
Ok(SnapshotFile {
path: relative.clone(),
media_type: media_type(&relative).to_string(),
size: bytes.len() as u64,
sha256: sha256(&bytes),
encoding,
content,
})
}
fn collect_paths(directory: &Path, paths: &mut Vec<PathBuf>) -> Result<(), String> {
let entries = fs::read_dir(directory)
.map_err(|e| format!("cannot read {}: {}", directory.display(), e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("cannot read directory entry: {}", e))?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)
.map_err(|e| format!("cannot inspect {}: {}", path.display(), e))?;
if metadata.file_type().is_symlink() {
return Err(format!("refusing to publish symlink: {}", path.display()));
}
if metadata.is_dir() {
collect_paths(&path, paths)?;
} else if metadata.is_file() {
paths.push(path);
}
}
Ok(())
}
fn source_metadata(folder: &Path, doc: &FurDocument) -> Result<Value, String> {
let origin_path = folder.join(ORIGIN_FILENAME);
let source = json!({
"format": "fur.conversation-directory",
"fur_document_schema": doc.schema,
"conversation_id": doc.conversation_id,
"origin_kind": "local",
"lineage": {
"parents": doc.parents,
"children": doc.children
},
"authors": document_authors(folder, doc),
"producer": {"fur_version": env!("CARGO_PKG_VERSION")}
});
let mut source = source;
if origin_path.exists() {
let text = fs::read_to_string(&origin_path)
.map_err(|e| format!("cannot read {}: {}", origin_path.display(), e))?;
let receipt: OriginReceipt = serde_json::from_str(&text)
.map_err(|e| format!("invalid {}: {}", origin_path.display(), e))?;
if receipt.source_conversation_id != doc.conversation_id {
return Err("origin receipt does not match this conversation".to_string());
}
source["origin_kind"] = json!("registry-import");
source["import_origin"] = json!({
"registry_id": receipt.registry_id,
"publication_id": receipt.publication_id,
"revision_id": receipt.revision_id,
"source_conversation_id": receipt.source_conversation_id,
"snapshot_digest": receipt.snapshot_digest
});
}
let published_path = folder.join(PUBLISHED_FILENAME);
if published_path.exists() {
let text = fs::read_to_string(&published_path)
.map_err(|e| format!("cannot read {}: {}", published_path.display(), e))?;
let receipt: PublishedReceipt = serde_json::from_str(&text)
.map_err(|e| format!("invalid {}: {}", published_path.display(), e))?;
source["publish_cursor"] = json!({
"registry_url": receipt.registry_url,
"registry_id": receipt.registry_id,
"publication_id": receipt.publication_id,
"revision_id": receipt.revision_id,
"snapshot_digest": receipt.snapshot_digest
});
}
Ok(source)
}
fn document_authors(folder: &Path, doc: &FurDocument) -> Vec<Value> {
let avatars = folder
.parent()
.and_then(|chats| chats.parent())
.map(|root| root.join(".fur").join("avatars.json"))
.and_then(|path| fs::read_to_string(path).ok())
.and_then(|text| serde_json::from_str::<Value>(&text).ok())
.unwrap_or_else(|| json!({}));
let mut names: Vec<&str> = doc
.messages
.iter()
.map(|message| message.avatar.as_str())
.filter(|name| !name.trim().is_empty())
.collect();
names.sort_unstable();
names.dedup();
names.sort_by_key(|name| crate::avatars::kind_of(&avatars, name) == "ai");
names
.into_iter()
.map(|name| {
let mut entry = json!({
"name": name,
"kind": crate::avatars::kind_of(&avatars, name)
});
if let Some(display) = crate::avatars::display_name_of(&avatars, name) {
entry["display_name"] = json!(display);
}
if let Some(role) = crate::avatars::role_of(&avatars, name) {
entry["role"] = json!(role);
}
entry
})
.collect()
}
fn relative_path(root: &Path, path: &Path) -> Result<String, String> {
let relative = path
.strip_prefix(root)
.map_err(|_| format!("{} is outside {}", path.display(), root.display()))?;
if relative.components().any(|part| !matches!(part, Component::Normal(_))) {
return Err(format!("unsafe snapshot path: {}", relative.display()));
}
let parts = relative
.components()
.map(|part| {
part.as_os_str()
.to_str()
.ok_or_else(|| format!("snapshot path is not UTF-8: {}", relative.display()))
})
.collect::<Result<Vec<_>, _>>()?;
let normalized = parts.join("/");
if normalized.contains('\\') {
return Err(format!("unsafe snapshot path: {}", relative.display()));
}
Ok(normalized)
}
fn manifest_digest(files: &[SnapshotFile]) -> String {
let mut hasher = Sha256::new();
for file in files {
hasher.update(file.path.as_bytes());
hasher.update([0]);
hasher.update(file.size.to_string().as_bytes());
hasher.update([0]);
hasher.update(file.sha256.as_bytes());
hasher.update(b"\n");
}
format!("{:x}", hasher.finalize())
}
fn sha256(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn media_type(path: &str) -> &'static str {
match Path::new(path).extension().and_then(|value| value.to_str()) {
Some("md") => "text/markdown",
Some("json") => "application/json",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
Some("pdf") => "application/pdf",
_ => "application/octet-stream",
}
}