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