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;
45mod nostr_client;
46
47use hashtree_config::Config;
48use helper::RemoteHelper;
49use nostr_client::resolve_identity;
50
51/// Entry point for the git remote helper
52/// Call this from main() to run the helper
53pub fn main_entry() {
54    // Install TLS crypto provider for reqwest/rustls
55    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    // Suppress broken pipe panics - git may close the pipe early
65    #[cfg(unix)]
66    {
67        unsafe {
68            libc::signal(libc::SIGPIPE, libc::SIG_DFL);
69        }
70    }
71
72    // Initialize logging - only show errors by default
73    // Set RUST_LOG=debug for verbose output
74    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    // Git calls us as: git-remote-htree <remote-name> <url>
87    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    // Parse URL: htree://<identifier>/<repo-name>#k=<secret>
97    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; // Self-only visibility
101
102    // Handle link-visible mode: either explicit key from URL or fail with setup instructions
103    let url_secret = if let Some(key) = parsed.secret_key {
104        // Explicit key from #k=<hex>
105        Some(key)
106    } else if parsed.auto_generate_secret {
107        // #link-visible - generate key and fail with setup instructions
108        let key = generate_secret_key();
109        let secret_hex = hex::encode(key);
110
111        // We need npub for the shareable URL, resolve identity first
112        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        // Exit without error code so git doesn't show confusing messages
142        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    // Resolve identifier to pubkey
154    // If "self" is used and no keys exist, auto-generate
155    let (pubkey, signing_key) = match resolve_identity(&identifier) {
156        Ok(result) => result,
157        Err(e) => {
158            // If resolution failed and user intended "self", suggest using htree://self/repo
159            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    // Convert pubkey to npub for display and shareable URLs
172    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    // Load config
182    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    // Check for local daemon and use it if available
188    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        // Prepend local daemon to read servers for cascade fetching
192        config.blossom.read_servers.insert(0, url.clone());
193    } else {
194        // Show hint once per session (git may call us multiple times)
195        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    // Create helper and run protocol
202    let mut helper =
203        RemoteHelper::new(&pubkey, &repo_name, signing_key, url_secret, is_private, config)?;
204
205    // Read commands from stdin, write responses to stdout
206    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                // Exit on error to avoid hanging
245                return Err(e);
246            }
247        }
248
249        if helper.should_exit() {
250            break;
251        }
252    }
253
254    Ok(())
255}
256
257/// Parsed htree URL components
258pub struct ParsedUrl {
259    pub identifier: String,
260    pub repo_name: String,
261    /// Secret key from #k=<hex> fragment (for link-visible repos)
262    pub secret_key: Option<[u8; 32]>,
263    /// Whether this is a private (self-only) repo from #private fragment
264    pub is_private: bool,
265    /// Whether to auto-generate a secret key (from #link-visible fragment)
266    pub auto_generate_secret: bool,
267}
268
269/// Parse htree:// URL into components
270/// Supports:
271/// - htree://identifier/repo - public repo
272/// - htree://identifier/repo#k=<hex> - link-visible repo with explicit key
273/// - htree://identifier/repo#link-visible - link-visible repo (auto-generate key)
274/// - htree://identifier/repo#private - private (self-only) repo
275fn parse_htree_url(url: &str) -> Result<ParsedUrl> {
276    let url = url
277        .strip_prefix("htree://")
278        .context("URL must start with htree://")?;
279
280    // Split off fragment (#k=secret, #link-visible, or #private) if present
281    let (url_path, secret_key, is_private, auto_generate_secret) = if let Some((path, fragment)) = url.split_once('#') {
282        if fragment == "private" {
283            // #private - self-only visibility
284            (path, None, true, false)
285        } else if fragment == "link-visible" {
286            // #link-visible - auto-generate key on push
287            (path, None, false, true)
288        } else if let Some(key_hex) = fragment.strip_prefix("k=") {
289            // #k=<hex> - link-visible with explicit key
290            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            // Unknown fragment - error to prevent accidental public push
300            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    // Split on first /
314    let (identifier, repo) = url_path
315        .split_once('/')
316        .context("URL must be htree://<identifier>/<repo>")?;
317
318    // Handle repo paths like "repo/subpath" - keep full path as repo name
319    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
337/// Generate a new random secret key for private repos
338pub 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
344/// Detect if local htree daemon is running
345/// Returns the daemon URL if available
346fn 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        // Secret too short
417        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        // Invalid hex characters
424        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        // Some git versions may pass URL with : instead of /
446        let result = parse_htree_url("htree://test:repo");
447        assert!(result.is_err()); // We don't support : syntax
448    }
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        // #k= is link-visible, not private
462        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        // No fragment = public
472        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        // #link-visible = auto-generate key
481        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()); // Key will be generated at runtime
486        assert!(parsed.auto_generate_secret);
487    }
488
489    #[test]
490    fn test_parse_htree_url_link_visible_explicit_key() {
491        // #k=<hex> = explicit key, not auto-generate
492        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); // Not auto-generated
497    }
498
499    #[test]
500    fn test_parse_htree_url_private_not_auto_generate() {
501        // #private is not auto_generate_secret
502        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        // When no daemon is running on port 8080, should return None
510        // This test assumes port 8080 is not in use during testing
511        let result = detect_local_daemon(None);
512        // Can't assert None because a daemon might be running
513        // Just verify it doesn't panic and returns valid result
514        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        // Bind to a random port
525        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}