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;
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
69/// Entry point for the git remote helper
70/// Call this from main() to run the helper
71pub fn main_entry() {
72    // Install TLS crypto provider for reqwest/rustls
73    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    // Suppress broken pipe signals - git may close the pipe early
83    #[cfg(unix)]
84    {
85        unsafe {
86            libc::signal(libc::SIGPIPE, libc::SIG_IGN);
87        }
88    }
89
90    // Initialize logging - only show errors by default
91    // Set RUST_LOG=debug for verbose output
92    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    // Git calls us as: git-remote-htree <remote-name> <url>
106    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    // Parse URL: htree://<identifier>/<repo-name>#k=<secret>
116    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; // Self-only visibility
120
121    // Handle link-visible mode: either explicit key from URL or fail with setup instructions
122    let url_secret = if let Some(key) = parsed.secret_key {
123        // Explicit key from #k=<hex>
124        Some(key)
125    } else if parsed.auto_generate_secret {
126        // #link-visible - generate key and fail with setup instructions
127        let key = generate_secret_key();
128        let secret_hex = hex::encode(key);
129
130        // We need npub for the shareable URL, resolve identity first
131        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        // Exit without error code so git doesn't show confusing messages
159        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    // Resolve identifier to pubkey
171    // If "self" is used and no keys exist, auto-generate
172    let (pubkey, signing_key) = match resolve_identity(&identifier) {
173        Ok(result) => result,
174        Err(e) => {
175            // If resolution failed and user intended "self", suggest using htree://self/repo
176            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    // Convert pubkey to npub for display and shareable URLs
189    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    // Load config
199    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    // Check for local daemon and use it if available
207    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        // Prepend local daemon to read servers for cascade fetching
211        config.blossom.read_servers.insert(0, url.clone());
212    } else {
213        // Show hint once per session (git may call us multiple times).
214        // Use logger so it only appears when RUST_LOG enables info/debug output.
215        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    // Create helper and run protocol
226    let mut helper = RemoteHelper::new(
227        &pubkey,
228        &repo_name,
229        signing_key,
230        url_secret,
231        is_private,
232        config,
233    )?;
234
235    // Read commands from stdin, write responses to stdout
236    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                // Exit on error to avoid hanging
283                return Err(e);
284            }
285        }
286
287        if helper.should_exit() {
288            break;
289        }
290    }
291
292    Ok(())
293}
294
295/// Parsed htree URL components
296pub struct ParsedUrl {
297    pub identifier: String,
298    pub repo_name: String,
299    /// Secret key from #k=<hex> fragment (for link-visible repos)
300    pub secret_key: Option<[u8; 32]>,
301    /// Whether this is a private (self-only) repo from #private fragment
302    pub is_private: bool,
303    /// Whether to auto-generate a secret key (from #link-visible fragment)
304    pub auto_generate_secret: bool,
305}
306
307/// Parse htree:// URL into components
308/// Supports:
309/// - htree://identifier/repo - public repo
310/// - htree://identifier/repo#k=<hex> - link-visible repo with explicit key
311/// - htree://identifier/repo#link-visible - link-visible repo (auto-generate key)
312/// - htree://identifier/repo#private - private (self-only) repo
313fn parse_htree_url(url: &str) -> Result<ParsedUrl> {
314    let url = url
315        .strip_prefix("htree://")
316        .context("URL must start with htree://")?;
317
318    // Split off fragment (#k=secret, #link-visible, or #private) if present
319    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            // #private - self-only visibility
324            (path, None, true, false)
325        } else if fragment == "link-visible" {
326            // #link-visible - auto-generate key on push
327            (path, None, false, true)
328        } else if let Some(key_hex) = fragment.strip_prefix("k=") {
329            // #k=<hex> - link-visible with explicit key
330            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            // Unknown fragment - error to prevent accidental public push
339            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    // Split on first /
353    let (identifier, repo) = url_path
354        .split_once('/')
355        .context("URL must be htree://<identifier>/<repo>")?;
356
357    // Handle repo paths like "repo/subpath" - keep full path as repo name
358    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
376/// Generate a new random secret key for private repos
377pub 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
383/// Detect if local htree daemon is running
384/// Returns the daemon URL if available
385fn 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        // Secret too short
457        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        // Invalid hex characters
464        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        // Some git versions may pass URL with : instead of /
487        let result = parse_htree_url("htree://test:repo");
488        assert!(result.is_err()); // We don't support : syntax
489    }
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        // #k= is link-visible, not private
503        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        // No fragment = public
513        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        // #link-visible = auto-generate key
522        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()); // Key will be generated at runtime
527        assert!(parsed.auto_generate_secret);
528    }
529
530    #[test]
531    fn test_parse_htree_url_link_visible_explicit_key() {
532        // #k=<hex> = explicit key, not auto-generate
533        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); // Not auto-generated
538    }
539
540    #[test]
541    fn test_parse_htree_url_private_not_auto_generate() {
542        // #private is not auto_generate_secret
543        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        // When no daemon is running on port 8080, should return None
551        // This test assumes port 8080 is not in use during testing
552        let result = detect_local_daemon(None);
553        // Can't assert None because a daemon might be running
554        // Just verify it doesn't panic and returns valid result
555        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        // Bind to a random port
566        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}