use anyhow::{bail, Context, Result};
use nostr_sdk::ToBech32;
use std::io::{BufRead, Write};
use tracing::{debug, info, warn};
pub mod git;
mod helper;
pub mod nostr_client;
mod runtime;
use hashtree_config::Config;
use helper::RemoteHelper;
use nostr_client::resolve_identity;
fn htree_binary_available() -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
for dir in std::env::split_paths(&path) {
let candidate = if cfg!(windows) {
dir.join("htree.exe")
} else {
dir.join("htree")
};
if candidate.is_file() {
return true;
}
}
false
}
pub fn main_entry() {
let _ = rustls::crypto::ring::default_provider().install_default();
if let Err(e) = run() {
eprintln!("Error: {:#}", e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
#[cfg(unix)]
{
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_IGN);
}
}
let env_filter = if std::env::var_os("RUST_LOG").is_some() {
tracing_subscriber::EnvFilter::from_default_env()
} else {
tracing_subscriber::EnvFilter::new("git_remote_htree=error,nostr_relay_pool=off")
};
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(std::io::stderr)
.init();
let args: Vec<String> = std::env::args().collect();
debug!("git-remote-htree called with args: {:?}", args);
if args.len() < 3 {
bail!("Usage: git-remote-htree <remote-name> <url>");
}
let remote_name = &args[1];
let url = &args[2];
info!("Remote: {}, URL: {}", remote_name, url);
let parsed = parse_htree_url(url)?;
let identifier = parsed.identifier;
let repo_name = parsed.repo_name;
let is_private = parsed.is_private;
let url_secret = if let Some(key) = parsed.secret_key {
Some(key)
} else if parsed.auto_generate_secret {
let key = generate_secret_key();
let secret_hex = hex::encode(key);
let npub = match resolve_identity(&identifier) {
Ok((pubkey, _)) => hex::decode(&pubkey)
.ok()
.filter(|b| b.len() == 32)
.and_then(|pk_bytes| nostr_sdk::PublicKey::from_slice(&pk_bytes).ok())
.and_then(|pk| pk.to_bech32().ok())
.unwrap_or(pubkey),
Err(_) => identifier.clone(),
};
let local_url = format!("htree://{}/{}#k={}", identifier, repo_name, secret_hex);
let share_url = format!("htree://{}/{}#k={}", npub, repo_name, secret_hex);
eprintln!();
eprintln!("=== Link-Visible Repository Setup ===");
eprintln!();
eprintln!("A secret key has been generated for this link-visible repository.");
eprintln!();
eprintln!("Step 1: Update your remote URL with the generated key:");
eprintln!(" git remote set-url {} {}", remote_name, local_url);
eprintln!();
eprintln!("Step 2: Push again (same command you just ran)");
eprintln!();
eprintln!("Shareable URL (for others to clone):");
eprintln!(" {}", share_url);
eprintln!();
std::process::exit(0);
} else {
None
};
if is_private {
info!("Private repo mode: only author can decrypt");
} else if url_secret.is_some() {
info!("Link-visible repo mode: using secret key from URL");
}
let (pubkey, signing_key) = match resolve_identity(&identifier) {
Ok(result) => result,
Err(e) => {
warn!("Failed to resolve identity '{}': {}", identifier, e);
info!("Tip: Use htree://self/<repo> to auto-generate identity on first use");
return Err(e);
}
};
if signing_key.is_some() {
debug!("Found signing key for {}", identifier);
} else {
debug!("No signing key for {} (read-only)", identifier);
}
let npub = hex::decode(&pubkey)
.ok()
.filter(|b| b.len() == 32)
.and_then(|pk_bytes| nostr_sdk::PublicKey::from_slice(&pk_bytes).ok())
.and_then(|pk| pk.to_bech32().ok())
.unwrap_or_else(|| pubkey.clone());
info!("Using identity: {}", npub);
let mut config = Config::load_or_default();
debug!(
"Loaded config with {} read servers, {} write servers",
config.blossom.read_servers.len(),
config.blossom.write_servers.len()
);
let daemon_url = detect_local_daemon(Some(&config.server.bind_address));
if let Some(ref url) = daemon_url {
debug!("Local daemon detected at {}", url);
config.blossom.read_servers.insert(0, url.clone());
} else {
static HINT_SHOWN: std::sync::Once = std::sync::Once::new();
HINT_SHOWN.call_once(|| {
if htree_binary_available() {
info!("Tip: run 'htree start' for P2P sharing");
} else {
info!("Tip: install htree (cargo install hashtree-cli) to enable P2P sharing");
}
});
}
let mut helper = RemoteHelper::new(
&pubkey,
&repo_name,
signing_key,
url_secret,
is_private,
config,
)?;
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
let trace_protocol = std::env::var_os("HTREE_TRACE_PROTOCOL").is_some();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
if e.kind() == std::io::ErrorKind::BrokenPipe {
break;
}
return Err(e.into());
}
};
debug!("< {}", line);
if trace_protocol {
eprintln!("[htree-proto] < {}", line);
}
match helper.handle_command(&line) {
Ok(Some(responses)) => {
for response in responses {
debug!("> {}", response);
if trace_protocol {
eprintln!("[htree-proto] > {}", response);
}
if let Err(e) = writeln!(stdout, "{}", response) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
break;
}
return Err(e.into());
}
}
if let Err(e) = stdout.flush() {
if e.kind() == std::io::ErrorKind::BrokenPipe {
break;
}
return Err(e.into());
}
}
Ok(None) => {}
Err(e) => {
warn!("Command error: {}", e);
return Err(e);
}
}
if helper.should_exit() {
break;
}
}
Ok(())
}
pub struct ParsedUrl {
pub identifier: String,
pub repo_name: String,
pub secret_key: Option<[u8; 32]>,
pub is_private: bool,
pub auto_generate_secret: bool,
}
fn parse_htree_url(url: &str) -> Result<ParsedUrl> {
let url = url
.strip_prefix("htree://")
.context("URL must start with htree://")?;
let (url_path, secret_key, is_private, auto_generate_secret) = if let Some((path, fragment)) =
url.split_once('#')
{
if fragment == "private" {
(path, None, true, false)
} else if fragment == "link-visible" {
(path, None, false, true)
} else if let Some(key_hex) = fragment.strip_prefix("k=") {
let bytes = hex::decode(key_hex).context("Invalid secret key hex in URL fragment")?;
if bytes.len() != 32 {
bail!("Secret key must be 32 bytes (64 hex chars)");
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
(path, Some(key), false, false)
} else {
bail!(
"Unknown URL fragment '#{}'. Valid options:\n\
- #k=<64-hex-chars> Link-visible with explicit key\n\
- #link-visible Link-visible with auto-generated key\n\
- #private Author-only (NIP-44 encrypted)\n\
- (no fragment) Public",
fragment
);
}
} else {
(url, None, false, false)
};
let (identifier, repo) = url_path
.split_once('/')
.context("URL must be htree://<identifier>/<repo>")?;
let repo_name = repo.to_string();
if identifier.is_empty() {
bail!("Identifier cannot be empty");
}
if repo_name.is_empty() {
bail!("Repository name cannot be empty");
}
Ok(ParsedUrl {
identifier: identifier.to_string(),
repo_name,
secret_key,
is_private,
auto_generate_secret,
})
}
pub fn generate_secret_key() -> [u8; 32] {
let mut key = [0u8; 32];
getrandom::fill(&mut key).expect("Failed to generate random bytes");
key
}
fn detect_local_daemon(bind_address: Option<&str>) -> Option<String> {
hashtree_config::detect_local_daemon_url(bind_address)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_htree_url_pubkey() {
let parsed = parse_htree_url(
"htree://a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8/myrepo",
)
.unwrap();
assert_eq!(
parsed.identifier,
"a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8"
);
assert_eq!(parsed.repo_name, "myrepo");
assert!(parsed.secret_key.is_none());
}
#[test]
fn test_parse_htree_url_npub() {
let parsed = parse_htree_url(
"htree://npub1qvmu0aru530g6yu3kmlhw33fh68r75wf3wuml3vk4ekg0p4m4t6s7fuhxx/test",
)
.unwrap();
assert!(parsed.identifier.starts_with("npub1"));
assert_eq!(parsed.repo_name, "test");
assert!(parsed.secret_key.is_none());
}
#[test]
fn test_parse_htree_url_petname() {
let parsed = parse_htree_url("htree://alice/project").unwrap();
assert_eq!(parsed.identifier, "alice");
assert_eq!(parsed.repo_name, "project");
assert!(parsed.secret_key.is_none());
}
#[test]
fn test_parse_htree_url_self() {
let parsed = parse_htree_url("htree://self/myrepo").unwrap();
assert_eq!(parsed.identifier, "self");
assert_eq!(parsed.repo_name, "myrepo");
assert!(parsed.secret_key.is_none());
}
#[test]
fn test_parse_htree_url_with_subpath() {
let parsed = parse_htree_url("htree://test/repo/some/path").unwrap();
assert_eq!(parsed.identifier, "test");
assert_eq!(parsed.repo_name, "repo/some/path");
assert!(parsed.secret_key.is_none());
}
#[test]
fn test_parse_htree_url_with_secret() {
let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let url = format!("htree://test/repo#k={}", secret_hex);
let parsed = parse_htree_url(&url).unwrap();
assert_eq!(parsed.identifier, "test");
assert_eq!(parsed.repo_name, "repo");
assert!(parsed.secret_key.is_some());
let key = parsed.secret_key.unwrap();
assert_eq!(hex::encode(key), secret_hex);
}
#[test]
fn test_parse_htree_url_invalid_secret_length() {
let url = "htree://test/repo#k=0123456789abcdef";
assert!(parse_htree_url(url).is_err());
}
#[test]
fn test_parse_htree_url_invalid_secret_hex() {
let url =
"htree://test/repo#k=ghij456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
assert!(parse_htree_url(url).is_err());
}
#[test]
fn test_parse_htree_url_invalid_scheme() {
assert!(parse_htree_url("https://example.com/repo").is_err());
}
#[test]
fn test_parse_htree_url_no_repo() {
assert!(parse_htree_url("htree://pubkey").is_err());
}
#[test]
fn test_parse_htree_url_empty_identifier() {
assert!(parse_htree_url("htree:///repo").is_err());
}
#[test]
fn test_parse_htree_url_colon() {
let result = parse_htree_url("htree://test:repo");
assert!(result.is_err()); }
#[test]
fn test_parse_htree_url_private() {
let parsed = parse_htree_url("htree://self/myrepo#private").unwrap();
assert_eq!(parsed.identifier, "self");
assert_eq!(parsed.repo_name, "myrepo");
assert!(parsed.is_private);
assert!(parsed.secret_key.is_none());
}
#[test]
fn test_parse_htree_url_secret_not_private() {
let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let url = format!("htree://test/repo#k={}", secret_hex);
let parsed = parse_htree_url(&url).unwrap();
assert!(!parsed.is_private);
assert!(parsed.secret_key.is_some());
}
#[test]
fn test_parse_htree_url_public() {
let parsed = parse_htree_url("htree://test/repo").unwrap();
assert!(!parsed.is_private);
assert!(parsed.secret_key.is_none());
assert!(!parsed.auto_generate_secret);
}
#[test]
fn test_parse_htree_url_link_visible_auto() {
let parsed = parse_htree_url("htree://self/myrepo#link-visible").unwrap();
assert_eq!(parsed.identifier, "self");
assert_eq!(parsed.repo_name, "myrepo");
assert!(!parsed.is_private);
assert!(parsed.secret_key.is_none()); assert!(parsed.auto_generate_secret);
}
#[test]
fn test_parse_htree_url_link_visible_explicit_key() {
let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let url = format!("htree://test/repo#k={}", secret_hex);
let parsed = parse_htree_url(&url).unwrap();
assert!(parsed.secret_key.is_some());
assert!(!parsed.auto_generate_secret); }
#[test]
fn test_parse_htree_url_private_not_auto_generate() {
let parsed = parse_htree_url("htree://self/myrepo#private").unwrap();
assert!(parsed.is_private);
assert!(!parsed.auto_generate_secret);
}
#[test]
fn test_detect_local_daemon_not_running() {
let result = detect_local_daemon(None);
if let Some(url) = result {
assert!(url.starts_with("http://"));
assert!(url.contains("8080"));
}
}
#[test]
fn test_detect_local_daemon_with_listener() {
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let addr = format!("127.0.0.1:{}", port);
let result = detect_local_daemon(Some(&addr));
assert!(result.is_none());
}
}