hashtree-cli 0.2.81

Hashtree daemon and CLI - content-addressed storage with P2P sync
Documentation
use anyhow::{Context, Result};
use hashtree_cli::config::parse_npub;
use hashtree_cli::storage::CachedRoot;
use hashtree_cli::{
    HashtreeStore, NostrKeys, NostrResolverConfig, NostrRootResolver, RootResolver,
};
use hashtree_core::Cid;
use hashtree_updater::UpdateRef;
use std::path::PathBuf;

/// Resolved CID with optional path.
pub(crate) struct ResolvedCid {
    pub(crate) cid: hashtree_core::Cid,
    pub(crate) path: Option<String>,
}

#[derive(Default, Clone)]
pub(crate) struct ResolveOptions {
    pub(crate) link_key: Option<[u8; 32]>,
    pub(crate) private: bool,
    pub(crate) relays: Option<Vec<String>>,
    pub(crate) secret_key: Option<NostrKeys>,
    pub(crate) data_dir: Option<PathBuf>,
}

pub(crate) fn parse_published_target(input: &str) -> Option<UpdateRef> {
    UpdateRef::parse(input).ok()
}

/// Resolve a CID input which can be:
/// - An nhash (bech32-encoded hash with optional key)
/// - An npub/repo path (e.g., "npub1.../myrepo")
/// - An htree:// URL (e.g., "htree://npub1.../myrepo")
///
/// Returns the resolved Cid (raw bytes) and optional path within the tree.
pub(crate) async fn resolve_cid_input(input: &str) -> Result<ResolvedCid> {
    resolve_cid_input_with_opts(input, &ResolveOptions::default()).await
}

pub(crate) async fn resolve_cid_input_with_opts(
    input: &str,
    opts: &ResolveOptions,
) -> Result<ResolvedCid> {
    use hashtree_core::{is_nhash, nhash_decode, Cid};

    let normalized_input = input.strip_prefix("htree://").unwrap_or(input);

    // Check if it's an nhash (bech32-encoded) - gives us raw bytes directly
    // Support nhash1.../path/to/file format (path suffix after slash)
    if is_nhash(normalized_input) {
        let (nhash_part, url_path) = if let Some(slash_pos) = normalized_input.find('/') {
            (
                &normalized_input[..slash_pos],
                Some(&normalized_input[slash_pos + 1..]),
            )
        } else {
            (normalized_input, None)
        };

        let data = nhash_decode(nhash_part).map_err(|e| anyhow::anyhow!("Invalid nhash: {}", e))?;

        return Ok(ResolvedCid {
            cid: Cid {
                hash: data.hash,
                key: data.decrypt_key,
            },
            path: url_path.map(|p| p.to_string()),
        });
    }

    // Check for hex CID format: "hash" or "hash:key", optionally with /path
    let (cid_part, url_path) = if let Some(slash_pos) = normalized_input.find('/') {
        (
            &normalized_input[..slash_pos],
            Some(&normalized_input[slash_pos + 1..]),
        )
    } else {
        (normalized_input, None)
    };
    if let Ok(cid) = Cid::parse(cid_part) {
        return Ok(ResolvedCid {
            cid,
            path: url_path.map(|p| p.to_string()),
        });
    }

    // Check if it looks like an npub path (npub1.../name or npub1.../name/path).
    if let Some(parsed_target) = parse_published_target(normalized_input) {
        let key = parsed_target.resolver_key();
        eprintln!("Resolving {}...", key);

        let mut config = NostrResolverConfig::default();
        if let Some(relays) = &opts.relays {
            config.relays = relays.clone();
        }
        if opts.private {
            config.secret_key = opts.secret_key.clone();
        }

        let resolver = NostrRootResolver::new(config)
            .await
            .context("Failed to create nostr resolver")?;

        let resolved = if let Some(link_key) = opts.link_key {
            resolver.resolve_shared(&key, &link_key).await
        } else {
            resolver.resolve(&key).await
        };

        match resolved {
            Ok(Some(cid)) => {
                eprintln!("Resolved to: {}", hashtree_core::to_hex(&cid.hash));
                return Ok(ResolvedCid {
                    cid,
                    path: parsed_target.path,
                });
            }
            Ok(None) => {
                if let Some(cached) = resolve_cached_published_target(&parsed_target, opts)? {
                    eprintln!(
                        "Using cached root for {}: {}",
                        key,
                        hashtree_core::to_hex(&cached.cid.hash)
                    );
                    return Ok(cached);
                }
                anyhow::bail!("No content found for {}", key);
            }
            Err(e) => {
                if let Some(cached) = resolve_cached_published_target(&parsed_target, opts)? {
                    eprintln!(
                        "Using cached root for {} after resolver error: {}",
                        key,
                        hashtree_core::to_hex(&cached.cid.hash)
                    );
                    return Ok(cached);
                }
                anyhow::bail!("Failed to resolve {}: {}", key, e);
            }
        }
    }

    anyhow::bail!("Invalid format. Use nhash1..., <hash>, <hash:key>, or npub1.../name")
}

fn resolve_cached_published_target(
    parsed_target: &UpdateRef,
    opts: &ResolveOptions,
) -> Result<Option<ResolvedCid>> {
    let Some(data_dir) = opts.data_dir.as_deref() else {
        return Ok(None);
    };

    let pubkey_hex = hex::encode(parse_npub(&parsed_target.npub)?);
    let store = HashtreeStore::new(data_dir)?;
    resolve_cached_published_target_from_cache(
        parsed_target,
        store.get_cached_root(&pubkey_hex, &parsed_target.tree_name)?,
    )
}

fn resolve_cached_published_target_from_cache(
    parsed_target: &UpdateRef,
    cached: Option<CachedRoot>,
) -> Result<Option<ResolvedCid>> {
    let Some(cached) = cached else {
        return Ok(None);
    };
    let cid = Cid {
        hash: hashtree_core::from_hex(&cached.hash)
            .map_err(|e| anyhow::anyhow!("Invalid cached root hash: {}", e))?,
        key: match cached.key.as_deref() {
            Some(key) => Some(
                hashtree_core::from_hex(key)
                    .map_err(|e| anyhow::anyhow!("Invalid cached root key: {}", e))?,
            ),
            None => None,
        },
    };

    Ok(Some(ResolvedCid {
        cid,
        path: parsed_target.path.clone(),
    }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use hashtree_cli::NostrToBech32;
    use nostr_sdk::Keys;

    #[test]
    fn resolve_cached_published_target_returns_cached_cid() {
        let keys = Keys::generate();
        let npub = NostrToBech32::to_bech32(&keys.public_key()).unwrap();
        let hash = "11".repeat(32);
        let key = "22".repeat(32);

        let parsed_target = UpdateRef {
            npub,
            tree_name: "mount-test".to_string(),
            path: Some("nested/file.txt".to_string()),
        };

        let resolved = resolve_cached_published_target_from_cache(
            &parsed_target,
            Some(CachedRoot {
                hash: hash.clone(),
                key: Some(key.clone()),
                updated_at: 123,
                visibility: "public".to_string(),
            }),
        )
        .unwrap()
        .expect("cached root");

        assert_eq!(hashtree_core::to_hex(&resolved.cid.hash), hash);
        assert_eq!(resolved.cid.key.map(hex::encode), Some(key));
        assert_eq!(resolved.path.as_deref(), Some("nested/file.txt"));
    }
}