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;
45mod 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, _)) => {
132 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 }
139 Err(_) => identifier.clone(),
140 };
141
142 let local_url = format!("htree://{}/{}#k={}", identifier, repo_name, secret_hex);
143 let share_url = format!("htree://{}/{}#k={}", npub, repo_name, secret_hex);
144
145 eprintln!();
146 eprintln!("=== Link-Visible Repository Setup ===");
147 eprintln!();
148 eprintln!("A secret key has been generated for this link-visible repository.");
149 eprintln!();
150 eprintln!("Step 1: Update your remote URL with the generated key:");
151 eprintln!(" git remote set-url {} {}", remote_name, local_url);
152 eprintln!();
153 eprintln!("Step 2: Push again (same command you just ran)");
154 eprintln!();
155 eprintln!("Shareable URL (for others to clone):");
156 eprintln!(" {}", share_url);
157 eprintln!();
158
159 std::process::exit(0);
161 } else {
162 None
163 };
164
165 if is_private {
166 info!("Private repo mode: only author can decrypt");
167 } else if url_secret.is_some() {
168 info!("Link-visible repo mode: using secret key from URL");
169 }
170
171 let (pubkey, signing_key) = match resolve_identity(&identifier) {
174 Ok(result) => result,
175 Err(e) => {
176 warn!("Failed to resolve identity '{}': {}", identifier, e);
178 info!("Tip: Use htree://self/<repo> to auto-generate identity on first use");
179 return Err(e);
180 }
181 };
182
183 if signing_key.is_some() {
184 debug!("Found signing key for {}", identifier);
185 } else {
186 debug!("No signing key for {} (read-only)", identifier);
187 }
188
189 let npub = hex::decode(&pubkey)
191 .ok()
192 .filter(|b| b.len() == 32)
193 .and_then(|pk_bytes| nostr_sdk::PublicKey::from_slice(&pk_bytes).ok())
194 .and_then(|pk| pk.to_bech32().ok())
195 .unwrap_or_else(|| pubkey.clone());
196
197 info!("Using identity: {}", npub);
198
199 let mut config = Config::load_or_default();
201 debug!("Loaded config with {} read servers, {} write servers",
202 config.blossom.read_servers.len(),
203 config.blossom.write_servers.len());
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();
214 HINT_SHOWN.call_once(|| {
215 if htree_binary_available() {
216 eprintln!("Tip: run 'htree start' for P2P sharing");
217 } else {
218 eprintln!("Tip: install htree (cargo install hashtree-cli) to enable P2P sharing");
219 }
220 });
221 }
222
223 let mut helper =
225 RemoteHelper::new(&pubkey, &repo_name, signing_key, url_secret, is_private, config)?;
226
227 let stdin = std::io::stdin();
229 let stdout = std::io::stdout();
230 let mut stdout = stdout.lock();
231
232 let trace_protocol = std::env::var_os("HTREE_TRACE_PROTOCOL").is_some();
233
234 for line in stdin.lock().lines() {
235 let line = match line {
236 Ok(l) => l,
237 Err(e) => {
238 if e.kind() == std::io::ErrorKind::BrokenPipe {
239 break;
240 }
241 return Err(e.into());
242 }
243 };
244
245 debug!("< {}", line);
246 if trace_protocol {
247 eprintln!("[htree-proto] < {}", line);
248 }
249
250 match helper.handle_command(&line) {
251 Ok(Some(responses)) => {
252 for response in responses {
253 debug!("> {}", response);
254 if trace_protocol {
255 eprintln!("[htree-proto] > {}", response);
256 }
257 if let Err(e) = writeln!(stdout, "{}", response) {
258 if e.kind() == std::io::ErrorKind::BrokenPipe {
259 break;
260 }
261 return Err(e.into());
262 }
263 }
264 if let Err(e) = stdout.flush() {
265 if e.kind() == std::io::ErrorKind::BrokenPipe {
266 break;
267 }
268 return Err(e.into());
269 }
270 }
271 Ok(None) => {}
272 Err(e) => {
273 warn!("Command error: {}", e);
274 return Err(e);
276 }
277 }
278
279 if helper.should_exit() {
280 break;
281 }
282 }
283
284 Ok(())
285}
286
287pub struct ParsedUrl {
289 pub identifier: String,
290 pub repo_name: String,
291 pub secret_key: Option<[u8; 32]>,
293 pub is_private: bool,
295 pub auto_generate_secret: bool,
297}
298
299fn parse_htree_url(url: &str) -> Result<ParsedUrl> {
306 let url = url
307 .strip_prefix("htree://")
308 .context("URL must start with htree://")?;
309
310 let (url_path, secret_key, is_private, auto_generate_secret) = if let Some((path, fragment)) = url.split_once('#') {
312 if fragment == "private" {
313 (path, None, true, false)
315 } else if fragment == "link-visible" {
316 (path, None, false, true)
318 } else if let Some(key_hex) = fragment.strip_prefix("k=") {
319 let bytes = hex::decode(key_hex)
321 .context("Invalid secret key hex in URL fragment")?;
322 if bytes.len() != 32 {
323 bail!("Secret key must be 32 bytes (64 hex chars)");
324 }
325 let mut key = [0u8; 32];
326 key.copy_from_slice(&bytes);
327 (path, Some(key), false, false)
328 } else {
329 bail!(
331 "Unknown URL fragment '#{}'. Valid options:\n\
332 - #k=<64-hex-chars> Link-visible with explicit key\n\
333 - #link-visible Link-visible with auto-generated key\n\
334 - #private Author-only (NIP-44 encrypted)\n\
335 - (no fragment) Public",
336 fragment
337 );
338 }
339 } else {
340 (url, None, false, false)
341 };
342
343 let (identifier, repo) = url_path
345 .split_once('/')
346 .context("URL must be htree://<identifier>/<repo>")?;
347
348 let repo_name = repo.to_string();
350
351 if identifier.is_empty() {
352 bail!("Identifier cannot be empty");
353 }
354 if repo_name.is_empty() {
355 bail!("Repository name cannot be empty");
356 }
357
358 Ok(ParsedUrl {
359 identifier: identifier.to_string(),
360 repo_name,
361 secret_key,
362 is_private,
363 auto_generate_secret,
364 })
365}
366
367pub fn generate_secret_key() -> [u8; 32] {
369 let mut key = [0u8; 32];
370 getrandom::fill(&mut key).expect("Failed to generate random bytes");
371 key
372}
373
374fn detect_local_daemon(bind_address: Option<&str>) -> Option<String> {
377 hashtree_config::detect_local_daemon_url(bind_address)
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn test_parse_htree_url_pubkey() {
386 let parsed = parse_htree_url(
387 "htree://a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8/myrepo",
388 )
389 .unwrap();
390 assert_eq!(
391 parsed.identifier,
392 "a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8"
393 );
394 assert_eq!(parsed.repo_name, "myrepo");
395 assert!(parsed.secret_key.is_none());
396 }
397
398 #[test]
399 fn test_parse_htree_url_npub() {
400 let parsed =
401 parse_htree_url("htree://npub1qvmu0aru530g6yu3kmlhw33fh68r75wf3wuml3vk4ekg0p4m4t6s7fuhxx/test")
402 .unwrap();
403 assert!(parsed.identifier.starts_with("npub1"));
404 assert_eq!(parsed.repo_name, "test");
405 assert!(parsed.secret_key.is_none());
406 }
407
408 #[test]
409 fn test_parse_htree_url_petname() {
410 let parsed = parse_htree_url("htree://alice/project").unwrap();
411 assert_eq!(parsed.identifier, "alice");
412 assert_eq!(parsed.repo_name, "project");
413 assert!(parsed.secret_key.is_none());
414 }
415
416 #[test]
417 fn test_parse_htree_url_self() {
418 let parsed = parse_htree_url("htree://self/myrepo").unwrap();
419 assert_eq!(parsed.identifier, "self");
420 assert_eq!(parsed.repo_name, "myrepo");
421 assert!(parsed.secret_key.is_none());
422 }
423
424 #[test]
425 fn test_parse_htree_url_with_subpath() {
426 let parsed = parse_htree_url("htree://test/repo/some/path").unwrap();
427 assert_eq!(parsed.identifier, "test");
428 assert_eq!(parsed.repo_name, "repo/some/path");
429 assert!(parsed.secret_key.is_none());
430 }
431
432 #[test]
433 fn test_parse_htree_url_with_secret() {
434 let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
435 let url = format!("htree://test/repo#k={}", secret_hex);
436 let parsed = parse_htree_url(&url).unwrap();
437 assert_eq!(parsed.identifier, "test");
438 assert_eq!(parsed.repo_name, "repo");
439 assert!(parsed.secret_key.is_some());
440 let key = parsed.secret_key.unwrap();
441 assert_eq!(hex::encode(key), secret_hex);
442 }
443
444 #[test]
445 fn test_parse_htree_url_invalid_secret_length() {
446 let url = "htree://test/repo#k=0123456789abcdef";
448 assert!(parse_htree_url(url).is_err());
449 }
450
451 #[test]
452 fn test_parse_htree_url_invalid_secret_hex() {
453 let url = "htree://test/repo#k=ghij456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
455 assert!(parse_htree_url(url).is_err());
456 }
457
458 #[test]
459 fn test_parse_htree_url_invalid_scheme() {
460 assert!(parse_htree_url("https://example.com/repo").is_err());
461 }
462
463 #[test]
464 fn test_parse_htree_url_no_repo() {
465 assert!(parse_htree_url("htree://pubkey").is_err());
466 }
467
468 #[test]
469 fn test_parse_htree_url_empty_identifier() {
470 assert!(parse_htree_url("htree:///repo").is_err());
471 }
472
473 #[test]
474 fn test_parse_htree_url_colon() {
475 let result = parse_htree_url("htree://test:repo");
477 assert!(result.is_err()); }
479
480 #[test]
481 fn test_parse_htree_url_private() {
482 let parsed = parse_htree_url("htree://self/myrepo#private").unwrap();
483 assert_eq!(parsed.identifier, "self");
484 assert_eq!(parsed.repo_name, "myrepo");
485 assert!(parsed.is_private);
486 assert!(parsed.secret_key.is_none());
487 }
488
489 #[test]
490 fn test_parse_htree_url_secret_not_private() {
491 let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
493 let url = format!("htree://test/repo#k={}", secret_hex);
494 let parsed = parse_htree_url(&url).unwrap();
495 assert!(!parsed.is_private);
496 assert!(parsed.secret_key.is_some());
497 }
498
499 #[test]
500 fn test_parse_htree_url_public() {
501 let parsed = parse_htree_url("htree://test/repo").unwrap();
503 assert!(!parsed.is_private);
504 assert!(parsed.secret_key.is_none());
505 assert!(!parsed.auto_generate_secret);
506 }
507
508 #[test]
509 fn test_parse_htree_url_link_visible_auto() {
510 let parsed = parse_htree_url("htree://self/myrepo#link-visible").unwrap();
512 assert_eq!(parsed.identifier, "self");
513 assert_eq!(parsed.repo_name, "myrepo");
514 assert!(!parsed.is_private);
515 assert!(parsed.secret_key.is_none()); assert!(parsed.auto_generate_secret);
517 }
518
519 #[test]
520 fn test_parse_htree_url_link_visible_explicit_key() {
521 let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
523 let url = format!("htree://test/repo#k={}", secret_hex);
524 let parsed = parse_htree_url(&url).unwrap();
525 assert!(parsed.secret_key.is_some());
526 assert!(!parsed.auto_generate_secret); }
528
529 #[test]
530 fn test_parse_htree_url_private_not_auto_generate() {
531 let parsed = parse_htree_url("htree://self/myrepo#private").unwrap();
533 assert!(parsed.is_private);
534 assert!(!parsed.auto_generate_secret);
535 }
536
537 #[test]
538 fn test_detect_local_daemon_not_running() {
539 let result = detect_local_daemon(None);
542 if let Some(url) = result {
545 assert!(url.starts_with("http://"));
546 assert!(url.contains("8080"));
547 }
548 }
549
550 #[test]
551 fn test_detect_local_daemon_with_listener() {
552 use std::net::TcpListener;
553
554 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
556 let port = listener.local_addr().unwrap().port();
557
558 drop(listener);
559 let addr = format!("127.0.0.1:{}", port);
560 let result = detect_local_daemon(Some(&addr));
561 assert!(result.is_none());
562 }
563}