1use anyhow::{bail, Context, Result};
39use nostr_sdk::ToBech32;
40use std::io::{BufRead, Write};
41use tracing::{debug, info, warn};
42
43pub mod git;
44mod helper;
45pub mod nostr_client;
46mod runtime;
47
48use hashtree_config::Config;
49use helper::RemoteHelper;
50use nostr_client::resolve_identity;
51
52fn htree_binary_available() -> bool {
53 let Some(path) = std::env::var_os("PATH") else {
54 return false;
55 };
56 for dir in std::env::split_paths(&path) {
57 let candidate = if cfg!(windows) {
58 dir.join("htree.exe")
59 } else {
60 dir.join("htree")
61 };
62 if candidate.is_file() {
63 return true;
64 }
65 }
66 false
67}
68
69pub fn main_entry() {
72 let _ = rustls::crypto::ring::default_provider().install_default();
74
75 if let Err(e) = run() {
76 eprintln!("Error: {:#}", e);
77 std::process::exit(1);
78 }
79}
80
81fn run() -> Result<()> {
82 #[cfg(unix)]
84 {
85 unsafe {
86 libc::signal(libc::SIGPIPE, libc::SIG_IGN);
87 }
88 }
89
90 let env_filter = if std::env::var_os("RUST_LOG").is_some() {
93 tracing_subscriber::EnvFilter::from_default_env()
94 } else {
95 tracing_subscriber::EnvFilter::new("git_remote_htree=error,nostr_relay_pool=off")
96 };
97 tracing_subscriber::fmt()
98 .with_env_filter(env_filter)
99 .with_writer(std::io::stderr)
100 .init();
101
102 let args: Vec<String> = std::env::args().collect();
103 debug!("git-remote-htree called with args: {:?}", args);
104
105 if args.len() < 3 {
107 bail!("Usage: git-remote-htree <remote-name> <url>");
108 }
109
110 let remote_name = &args[1];
111 let url = &args[2];
112
113 info!("Remote: {}, URL: {}", remote_name, url);
114
115 let parsed = parse_htree_url(url)?;
117 let identifier = parsed.identifier;
118 let repo_name = parsed.repo_name;
119 let is_private = parsed.is_private; let url_secret = if let Some(key) = parsed.secret_key {
123 Some(key)
125 } else if parsed.auto_generate_secret {
126 let key = generate_secret_key();
128 let secret_hex = hex::encode(key);
129
130 let npub = match resolve_identity(&identifier) {
132 Ok((pubkey, _)) => hex::decode(&pubkey)
133 .ok()
134 .filter(|b| b.len() == 32)
135 .and_then(|pk_bytes| nostr_sdk::PublicKey::from_slice(&pk_bytes).ok())
136 .and_then(|pk| pk.to_bech32().ok())
137 .unwrap_or(pubkey),
138 Err(_) => identifier.clone(),
139 };
140
141 let local_url = format!("htree://{}/{}#k={}", identifier, repo_name, secret_hex);
142 let share_url = format!("htree://{}/{}#k={}", npub, repo_name, secret_hex);
143
144 eprintln!();
145 eprintln!("=== Link-Visible Repository Setup ===");
146 eprintln!();
147 eprintln!("A secret key has been generated for this link-visible repository.");
148 eprintln!();
149 eprintln!("Step 1: Update your remote URL with the generated key:");
150 eprintln!(" git remote set-url {} {}", remote_name, local_url);
151 eprintln!();
152 eprintln!("Step 2: Push again (same command you just ran)");
153 eprintln!();
154 eprintln!("Shareable URL (for others to clone):");
155 eprintln!(" {}", share_url);
156 eprintln!();
157
158 std::process::exit(0);
160 } else {
161 None
162 };
163
164 if is_private {
165 info!("Private repo mode: only author can decrypt");
166 } else if url_secret.is_some() {
167 info!("Link-visible repo mode: using secret key from URL");
168 }
169
170 let (pubkey, signing_key) = match resolve_identity(&identifier) {
173 Ok(result) => result,
174 Err(e) => {
175 warn!("Failed to resolve identity '{}': {}", identifier, e);
177 info!("Tip: Use htree://self/<repo> to auto-generate identity on first use");
178 return Err(e);
179 }
180 };
181
182 if signing_key.is_some() {
183 debug!("Found signing key for {}", identifier);
184 } else {
185 debug!("No signing key for {} (read-only)", identifier);
186 }
187
188 let npub = hex::decode(&pubkey)
190 .ok()
191 .filter(|b| b.len() == 32)
192 .and_then(|pk_bytes| nostr_sdk::PublicKey::from_slice(&pk_bytes).ok())
193 .and_then(|pk| pk.to_bech32().ok())
194 .unwrap_or_else(|| pubkey.clone());
195
196 info!("Using identity: {}", npub);
197
198 let mut config = Config::load_or_default();
200 debug!(
201 "Loaded config with {} read servers, {} write servers",
202 config.blossom.read_servers.len(),
203 config.blossom.write_servers.len()
204 );
205
206 let daemon_url = detect_local_daemon(Some(&config.server.bind_address));
208 if let Some(ref url) = daemon_url {
209 debug!("Local daemon detected at {}", url);
210 config.blossom.read_servers.insert(0, url.clone());
212 } else {
213 static HINT_SHOWN: std::sync::Once = std::sync::Once::new();
216 HINT_SHOWN.call_once(|| {
217 if htree_binary_available() {
218 info!("Tip: run 'htree start' for P2P sharing");
219 } else {
220 info!("Tip: install htree (cargo install hashtree-cli) to enable P2P sharing");
221 }
222 });
223 }
224
225 let mut helper = RemoteHelper::new(
227 &pubkey,
228 &repo_name,
229 signing_key,
230 url_secret,
231 is_private,
232 config,
233 )?;
234
235 let stdin = std::io::stdin();
237 let stdout = std::io::stdout();
238 let mut stdout = stdout.lock();
239
240 let trace_protocol = std::env::var_os("HTREE_TRACE_PROTOCOL").is_some();
241
242 for line in stdin.lock().lines() {
243 let line = match line {
244 Ok(l) => l,
245 Err(e) => {
246 if e.kind() == std::io::ErrorKind::BrokenPipe {
247 break;
248 }
249 return Err(e.into());
250 }
251 };
252
253 debug!("< {}", line);
254 if trace_protocol {
255 eprintln!("[htree-proto] < {}", line);
256 }
257
258 match helper.handle_command(&line) {
259 Ok(Some(responses)) => {
260 for response in responses {
261 debug!("> {}", response);
262 if trace_protocol {
263 eprintln!("[htree-proto] > {}", response);
264 }
265 if let Err(e) = writeln!(stdout, "{}", response) {
266 if e.kind() == std::io::ErrorKind::BrokenPipe {
267 break;
268 }
269 return Err(e.into());
270 }
271 }
272 if let Err(e) = stdout.flush() {
273 if e.kind() == std::io::ErrorKind::BrokenPipe {
274 break;
275 }
276 return Err(e.into());
277 }
278 }
279 Ok(None) => {}
280 Err(e) => {
281 warn!("Command error: {}", e);
282 return Err(e);
284 }
285 }
286
287 if helper.should_exit() {
288 break;
289 }
290 }
291
292 Ok(())
293}
294
295pub struct ParsedUrl {
297 pub identifier: String,
298 pub repo_name: String,
299 pub secret_key: Option<[u8; 32]>,
301 pub is_private: bool,
303 pub auto_generate_secret: bool,
305}
306
307fn parse_htree_url(url: &str) -> Result<ParsedUrl> {
314 let url = url
315 .strip_prefix("htree://")
316 .context("URL must start with htree://")?;
317
318 let (url_path, secret_key, is_private, auto_generate_secret) = if let Some((path, fragment)) =
320 url.split_once('#')
321 {
322 if fragment == "private" {
323 (path, None, true, false)
325 } else if fragment == "link-visible" {
326 (path, None, false, true)
328 } else if let Some(key_hex) = fragment.strip_prefix("k=") {
329 let bytes = hex::decode(key_hex).context("Invalid secret key hex in URL fragment")?;
331 if bytes.len() != 32 {
332 bail!("Secret key must be 32 bytes (64 hex chars)");
333 }
334 let mut key = [0u8; 32];
335 key.copy_from_slice(&bytes);
336 (path, Some(key), false, false)
337 } else {
338 bail!(
340 "Unknown URL fragment '#{}'. Valid options:\n\
341 - #k=<64-hex-chars> Link-visible with explicit key\n\
342 - #link-visible Link-visible with auto-generated key\n\
343 - #private Author-only (NIP-44 encrypted)\n\
344 - (no fragment) Public",
345 fragment
346 );
347 }
348 } else {
349 (url, None, false, false)
350 };
351
352 let (identifier, repo) = url_path
354 .split_once('/')
355 .context("URL must be htree://<identifier>/<repo>")?;
356
357 let repo_name = repo.to_string();
359
360 if identifier.is_empty() {
361 bail!("Identifier cannot be empty");
362 }
363 if repo_name.is_empty() {
364 bail!("Repository name cannot be empty");
365 }
366
367 Ok(ParsedUrl {
368 identifier: identifier.to_string(),
369 repo_name,
370 secret_key,
371 is_private,
372 auto_generate_secret,
373 })
374}
375
376pub fn generate_secret_key() -> [u8; 32] {
378 let mut key = [0u8; 32];
379 getrandom::fill(&mut key).expect("Failed to generate random bytes");
380 key
381}
382
383fn detect_local_daemon(bind_address: Option<&str>) -> Option<String> {
386 hashtree_config::detect_local_daemon_url(bind_address)
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_parse_htree_url_pubkey() {
395 let parsed = parse_htree_url(
396 "htree://a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8/myrepo",
397 )
398 .unwrap();
399 assert_eq!(
400 parsed.identifier,
401 "a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8"
402 );
403 assert_eq!(parsed.repo_name, "myrepo");
404 assert!(parsed.secret_key.is_none());
405 }
406
407 #[test]
408 fn test_parse_htree_url_npub() {
409 let parsed = parse_htree_url(
410 "htree://npub1qvmu0aru530g6yu3kmlhw33fh68r75wf3wuml3vk4ekg0p4m4t6s7fuhxx/test",
411 )
412 .unwrap();
413 assert!(parsed.identifier.starts_with("npub1"));
414 assert_eq!(parsed.repo_name, "test");
415 assert!(parsed.secret_key.is_none());
416 }
417
418 #[test]
419 fn test_parse_htree_url_petname() {
420 let parsed = parse_htree_url("htree://alice/project").unwrap();
421 assert_eq!(parsed.identifier, "alice");
422 assert_eq!(parsed.repo_name, "project");
423 assert!(parsed.secret_key.is_none());
424 }
425
426 #[test]
427 fn test_parse_htree_url_self() {
428 let parsed = parse_htree_url("htree://self/myrepo").unwrap();
429 assert_eq!(parsed.identifier, "self");
430 assert_eq!(parsed.repo_name, "myrepo");
431 assert!(parsed.secret_key.is_none());
432 }
433
434 #[test]
435 fn test_parse_htree_url_with_subpath() {
436 let parsed = parse_htree_url("htree://test/repo/some/path").unwrap();
437 assert_eq!(parsed.identifier, "test");
438 assert_eq!(parsed.repo_name, "repo/some/path");
439 assert!(parsed.secret_key.is_none());
440 }
441
442 #[test]
443 fn test_parse_htree_url_with_secret() {
444 let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
445 let url = format!("htree://test/repo#k={}", secret_hex);
446 let parsed = parse_htree_url(&url).unwrap();
447 assert_eq!(parsed.identifier, "test");
448 assert_eq!(parsed.repo_name, "repo");
449 assert!(parsed.secret_key.is_some());
450 let key = parsed.secret_key.unwrap();
451 assert_eq!(hex::encode(key), secret_hex);
452 }
453
454 #[test]
455 fn test_parse_htree_url_invalid_secret_length() {
456 let url = "htree://test/repo#k=0123456789abcdef";
458 assert!(parse_htree_url(url).is_err());
459 }
460
461 #[test]
462 fn test_parse_htree_url_invalid_secret_hex() {
463 let url =
465 "htree://test/repo#k=ghij456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
466 assert!(parse_htree_url(url).is_err());
467 }
468
469 #[test]
470 fn test_parse_htree_url_invalid_scheme() {
471 assert!(parse_htree_url("https://example.com/repo").is_err());
472 }
473
474 #[test]
475 fn test_parse_htree_url_no_repo() {
476 assert!(parse_htree_url("htree://pubkey").is_err());
477 }
478
479 #[test]
480 fn test_parse_htree_url_empty_identifier() {
481 assert!(parse_htree_url("htree:///repo").is_err());
482 }
483
484 #[test]
485 fn test_parse_htree_url_colon() {
486 let result = parse_htree_url("htree://test:repo");
488 assert!(result.is_err()); }
490
491 #[test]
492 fn test_parse_htree_url_private() {
493 let parsed = parse_htree_url("htree://self/myrepo#private").unwrap();
494 assert_eq!(parsed.identifier, "self");
495 assert_eq!(parsed.repo_name, "myrepo");
496 assert!(parsed.is_private);
497 assert!(parsed.secret_key.is_none());
498 }
499
500 #[test]
501 fn test_parse_htree_url_secret_not_private() {
502 let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
504 let url = format!("htree://test/repo#k={}", secret_hex);
505 let parsed = parse_htree_url(&url).unwrap();
506 assert!(!parsed.is_private);
507 assert!(parsed.secret_key.is_some());
508 }
509
510 #[test]
511 fn test_parse_htree_url_public() {
512 let parsed = parse_htree_url("htree://test/repo").unwrap();
514 assert!(!parsed.is_private);
515 assert!(parsed.secret_key.is_none());
516 assert!(!parsed.auto_generate_secret);
517 }
518
519 #[test]
520 fn test_parse_htree_url_link_visible_auto() {
521 let parsed = parse_htree_url("htree://self/myrepo#link-visible").unwrap();
523 assert_eq!(parsed.identifier, "self");
524 assert_eq!(parsed.repo_name, "myrepo");
525 assert!(!parsed.is_private);
526 assert!(parsed.secret_key.is_none()); assert!(parsed.auto_generate_secret);
528 }
529
530 #[test]
531 fn test_parse_htree_url_link_visible_explicit_key() {
532 let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
534 let url = format!("htree://test/repo#k={}", secret_hex);
535 let parsed = parse_htree_url(&url).unwrap();
536 assert!(parsed.secret_key.is_some());
537 assert!(!parsed.auto_generate_secret); }
539
540 #[test]
541 fn test_parse_htree_url_private_not_auto_generate() {
542 let parsed = parse_htree_url("htree://self/myrepo#private").unwrap();
544 assert!(parsed.is_private);
545 assert!(!parsed.auto_generate_secret);
546 }
547
548 #[test]
549 fn test_detect_local_daemon_not_running() {
550 let result = detect_local_daemon(None);
553 if let Some(url) = result {
556 assert!(url.starts_with("http://"));
557 assert!(url.contains("8080"));
558 }
559 }
560
561 #[test]
562 fn test_detect_local_daemon_with_listener() {
563 use std::net::TcpListener;
564
565 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
567 let port = listener.local_addr().unwrap().port();
568
569 drop(listener);
570 let addr = format!("127.0.0.1:{}", port);
571 let result = detect_local_daemon(Some(&addr));
572 assert!(result.is_none());
573 }
574}