Skip to main content

git_remote_htree/
lib.rs

1//! Git remote helper for hashtree
2//!
3//! This crate provides a git remote helper that allows pushing/pulling
4//! git repositories via nostr and hashtree.
5//!
6//! ## Usage
7//!
8//! ```bash
9//! git remote add origin htree://<pubkey>/<repo-name>
10//! git remote add origin htree://<petname>/<repo-name>
11//! git push origin main
12//! git pull origin main
13//! ```
14//!
15//! ## Encryption Modes
16//!
17//! - **Unencrypted**: No CHK, just hash - anyone with hash can read
18//! - **Public**: CHK encrypted, `["key", "<hex>"]` in event - anyone can decrypt
19//! - **Link-visible**: CHK + XOR mask, `["encryptedKey", XOR(key,secret)]` - need `#k=<secret>` URL
20//! - **Private**: CHK + NIP-44 to self, `["selfEncryptedKey", "..."]` - author only
21//!
22//! Default is **Public** (CHK encrypted, key in nostr event).
23//!
24//! ## Creating Link-Visible Repos
25//!
26//! To create a link-visible repo, use `#link-visible` to auto-generate a key:
27//! ```bash
28//! git remote add origin htree://self/repo#link-visible
29//! git push origin main
30//! # After push, you'll see instructions to update the remote URL with the generated key
31//! ```
32//!
33//! Or specify an explicit key with `#k=<secret>`:
34//! ```bash
35//! git remote add origin htree://npub/repo#k=<64-hex-chars>
36//! ```
37
38use 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
68/// Entry point for the git remote helper
69/// Call this from main() to run the helper
70pub fn main_entry() {
71    // Install TLS crypto provider for reqwest/rustls
72    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    // Suppress broken pipe signals - git may close the pipe early
82    #[cfg(unix)]
83    {
84        unsafe {
85            libc::signal(libc::SIGPIPE, libc::SIG_IGN);
86        }
87    }
88
89    // Initialize logging - only show errors by default
90    // Set RUST_LOG=debug for verbose output
91    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    // Git calls us as: git-remote-htree <remote-name> <url>
105    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    // Parse URL: htree://<identifier>/<repo-name>#k=<secret>
115    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; // Self-only visibility
119
120    // Handle link-visible mode: either explicit key from URL or fail with setup instructions
121    let url_secret = if let Some(key) = parsed.secret_key {
122        // Explicit key from #k=<hex>
123        Some(key)
124    } else if parsed.auto_generate_secret {
125        // #link-visible - generate key and fail with setup instructions
126        let key = generate_secret_key();
127        let secret_hex = hex::encode(key);
128
129        // We need npub for the shareable URL, resolve identity first
130        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        // Exit without error code so git doesn't show confusing messages
158        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    // Resolve identifier to pubkey
170    // If "self" is used and no keys exist, auto-generate
171    let (pubkey, signing_key) = match resolve_identity(&identifier) {
172        Ok(result) => result,
173        Err(e) => {
174            // If resolution failed and user intended "self", suggest using htree://self/repo
175            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    // Convert pubkey to npub for display and shareable URLs
188    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    // Load config
198    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    // Check for local daemon and use it if available
206    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        // Prepend local daemon to read servers for cascade fetching
210        config.blossom.read_servers.insert(0, url.clone());
211    } else {
212        // Show hint once per session (git may call us multiple times).
213        // Use logger so it only appears when RUST_LOG enables info/debug output.
214        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    // Create helper and run protocol
225    let mut helper = RemoteHelper::new(
226        &pubkey,
227        &repo_name,
228        signing_key,
229        url_secret,
230        is_private,
231        config,
232    )?;
233
234    // Read commands from stdin, write responses to stdout
235    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                // Exit on error to avoid hanging
282                return Err(e);
283            }
284        }
285
286        if helper.should_exit() {
287            break;
288        }
289    }
290
291    Ok(())
292}
293
294/// Parsed htree URL components
295pub struct ParsedUrl {
296    pub identifier: String,
297    pub repo_name: String,
298    /// Secret key from #k=<hex> fragment (for link-visible repos)
299    pub secret_key: Option<[u8; 32]>,
300    /// Whether this is a private (self-only) repo from #private fragment
301    pub is_private: bool,
302    /// Whether to auto-generate a secret key (from #link-visible fragment)
303    pub auto_generate_secret: bool,
304}
305
306/// Parse htree:// URL into components
307/// Supports:
308/// - htree://identifier/repo - public repo
309/// - htree://identifier/repo#k=<hex> - link-visible repo with explicit key
310/// - htree://identifier/repo#link-visible - link-visible repo (auto-generate key)
311/// - htree://identifier/repo#private - private (self-only) repo
312fn parse_htree_url(url: &str) -> Result<ParsedUrl> {
313    let url = url
314        .strip_prefix("htree://")
315        .context("URL must start with htree://")?;
316
317    // Split off fragment (#k=secret, #link-visible, or #private) if present
318    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            // #private - self-only visibility
323            (path, None, true, false)
324        } else if fragment == "link-visible" {
325            // #link-visible - auto-generate key on push
326            (path, None, false, true)
327        } else if let Some(key_hex) = fragment.strip_prefix("k=") {
328            // #k=<hex> - link-visible with explicit key
329            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            // Unknown fragment - error to prevent accidental public push
338            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    // Split on first /
352    let (identifier, repo) = url_path
353        .split_once('/')
354        .context("URL must be htree://<identifier>/<repo>")?;
355
356    // Handle repo paths like "repo/subpath" - keep full path as repo name
357    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
375/// Generate a new random secret key for private repos
376pub 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
382/// Detect if local htree daemon is running
383/// Returns the daemon URL if available
384fn 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        // Secret too short
456        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        // Invalid hex characters
463        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        // Some git versions may pass URL with : instead of /
486        let result = parse_htree_url("htree://test:repo");
487        assert!(result.is_err()); // We don't support : syntax
488    }
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        // #k= is link-visible, not private
502        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        // No fragment = public
512        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        // #link-visible = auto-generate key
521        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()); // Key will be generated at runtime
526        assert!(parsed.auto_generate_secret);
527    }
528
529    #[test]
530    fn test_parse_htree_url_link_visible_explicit_key() {
531        // #k=<hex> = explicit key, not auto-generate
532        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); // Not auto-generated
537    }
538
539    #[test]
540    fn test_parse_htree_url_private_not_auto_generate() {
541        // #private is not auto_generate_secret
542        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        // When no daemon is running on port 8080, should return None
550        // This test assumes port 8080 is not in use during testing
551        let result = detect_local_daemon(None);
552        // Can't assert None because a daemon might be running
553        // Just verify it doesn't panic and returns valid result
554        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        // Bind to a random port
565        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}