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