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;
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
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, _)) => {
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        // Exit without error code so git doesn't show confusing messages
160        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    // Resolve identifier to pubkey
172    // If "self" is used and no keys exist, auto-generate
173    let (pubkey, signing_key) = match resolve_identity(&identifier) {
174        Ok(result) => result,
175        Err(e) => {
176            // If resolution failed and user intended "self", suggest using htree://self/repo
177            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    // Convert pubkey to npub for display and shareable URLs
190    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    // Load config
200    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    // 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        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    // Create helper and run protocol
224    let mut helper =
225        RemoteHelper::new(&pubkey, &repo_name, signing_key, url_secret, is_private, config)?;
226
227    // Read commands from stdin, write responses to stdout
228    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                // Exit on error to avoid hanging
275                return Err(e);
276            }
277        }
278
279        if helper.should_exit() {
280            break;
281        }
282    }
283
284    Ok(())
285}
286
287/// Parsed htree URL components
288pub struct ParsedUrl {
289    pub identifier: String,
290    pub repo_name: String,
291    /// Secret key from #k=<hex> fragment (for link-visible repos)
292    pub secret_key: Option<[u8; 32]>,
293    /// Whether this is a private (self-only) repo from #private fragment
294    pub is_private: bool,
295    /// Whether to auto-generate a secret key (from #link-visible fragment)
296    pub auto_generate_secret: bool,
297}
298
299/// Parse htree:// URL into components
300/// Supports:
301/// - htree://identifier/repo - public repo
302/// - htree://identifier/repo#k=<hex> - link-visible repo with explicit key
303/// - htree://identifier/repo#link-visible - link-visible repo (auto-generate key)
304/// - htree://identifier/repo#private - private (self-only) repo
305fn parse_htree_url(url: &str) -> Result<ParsedUrl> {
306    let url = url
307        .strip_prefix("htree://")
308        .context("URL must start with htree://")?;
309
310    // Split off fragment (#k=secret, #link-visible, or #private) if present
311    let (url_path, secret_key, is_private, auto_generate_secret) = if let Some((path, fragment)) = url.split_once('#') {
312        if fragment == "private" {
313            // #private - self-only visibility
314            (path, None, true, false)
315        } else if fragment == "link-visible" {
316            // #link-visible - auto-generate key on push
317            (path, None, false, true)
318        } else if let Some(key_hex) = fragment.strip_prefix("k=") {
319            // #k=<hex> - link-visible with explicit key
320            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            // Unknown fragment - error to prevent accidental public push
330            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    // Split on first /
344    let (identifier, repo) = url_path
345        .split_once('/')
346        .context("URL must be htree://<identifier>/<repo>")?;
347
348    // Handle repo paths like "repo/subpath" - keep full path as repo name
349    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
367/// Generate a new random secret key for private repos
368pub 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
374/// Detect if local htree daemon is running
375/// Returns the daemon URL if available
376fn 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        // Secret too short
447        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        // Invalid hex characters
454        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        // Some git versions may pass URL with : instead of /
476        let result = parse_htree_url("htree://test:repo");
477        assert!(result.is_err()); // We don't support : syntax
478    }
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        // #k= is link-visible, not private
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.is_private);
496        assert!(parsed.secret_key.is_some());
497    }
498
499    #[test]
500    fn test_parse_htree_url_public() {
501        // No fragment = public
502        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        // #link-visible = auto-generate key
511        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()); // Key will be generated at runtime
516        assert!(parsed.auto_generate_secret);
517    }
518
519    #[test]
520    fn test_parse_htree_url_link_visible_explicit_key() {
521        // #k=<hex> = explicit key, not auto-generate
522        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); // Not auto-generated
527    }
528
529    #[test]
530    fn test_parse_htree_url_private_not_auto_generate() {
531        // #private is not auto_generate_secret
532        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        // When no daemon is running on port 8080, should return None
540        // This test assumes port 8080 is not in use during testing
541        let result = detect_local_daemon(None);
542        // Can't assert None because a daemon might be running
543        // Just verify it doesn't panic and returns valid result
544        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        // Bind to a random port
555        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}