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
43mod 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 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    // Create helper and run protocol
188    let mut helper = RemoteHelper::new(&pubkey, &repo_name, signing_key, url_secret, is_private, config)?;
189
190    // Read commands from stdin, write responses to stdout
191    let stdin = std::io::stdin();
192    let stdout = std::io::stdout();
193    let mut stdout = stdout.lock();
194
195    for line in stdin.lock().lines() {
196        let line = match line {
197            Ok(l) => l,
198            Err(e) => {
199                if e.kind() == std::io::ErrorKind::BrokenPipe {
200                    break;
201                }
202                return Err(e.into());
203            }
204        };
205
206        debug!("< {}", line);
207
208        match helper.handle_command(&line) {
209            Ok(Some(responses)) => {
210                for response in responses {
211                    debug!("> {}", response);
212                    if let Err(e) = writeln!(stdout, "{}", response) {
213                        if e.kind() == std::io::ErrorKind::BrokenPipe {
214                            break;
215                        }
216                        return Err(e.into());
217                    }
218                }
219                if let Err(e) = stdout.flush() {
220                    if e.kind() == std::io::ErrorKind::BrokenPipe {
221                        break;
222                    }
223                    return Err(e.into());
224                }
225            }
226            Ok(None) => {}
227            Err(e) => {
228                warn!("Command error: {}", e);
229                // Exit on error to avoid hanging
230                return Err(e);
231            }
232        }
233
234        if helper.should_exit() {
235            break;
236        }
237    }
238
239    Ok(())
240}
241
242/// Parsed htree URL components
243pub struct ParsedUrl {
244    pub identifier: String,
245    pub repo_name: String,
246    /// Secret key from #k=<hex> fragment (for link-visible repos)
247    pub secret_key: Option<[u8; 32]>,
248    /// Whether this is a private (self-only) repo from #private fragment
249    pub is_private: bool,
250    /// Whether to auto-generate a secret key (from #link-visible fragment)
251    pub auto_generate_secret: bool,
252}
253
254/// Parse htree:// URL into components
255/// Supports:
256/// - htree://identifier/repo - public repo
257/// - htree://identifier/repo#k=<hex> - link-visible repo with explicit key
258/// - htree://identifier/repo#link-visible - link-visible repo (auto-generate key)
259/// - htree://identifier/repo#private - private (self-only) repo
260fn parse_htree_url(url: &str) -> Result<ParsedUrl> {
261    let url = url
262        .strip_prefix("htree://")
263        .context("URL must start with htree://")?;
264
265    // Split off fragment (#k=secret, #link-visible, or #private) if present
266    let (url_path, secret_key, is_private, auto_generate_secret) = if let Some((path, fragment)) = url.split_once('#') {
267        if fragment == "private" {
268            // #private - self-only visibility
269            (path, None, true, false)
270        } else if fragment == "link-visible" {
271            // #link-visible - auto-generate key on push
272            (path, None, false, true)
273        } else if let Some(key_hex) = fragment.strip_prefix("k=") {
274            // #k=<hex> - link-visible with explicit key
275            let bytes = hex::decode(key_hex)
276                .context("Invalid secret key hex in URL fragment")?;
277            if bytes.len() != 32 {
278                bail!("Secret key must be 32 bytes (64 hex chars)");
279            }
280            let mut key = [0u8; 32];
281            key.copy_from_slice(&bytes);
282            (path, Some(key), false, false)
283        } else {
284            // Unknown fragment - error to prevent accidental public push
285            bail!(
286                "Unknown URL fragment '#{}'. Valid options:\n\
287                 - #k=<64-hex-chars>  Link-visible with explicit key\n\
288                 - #link-visible      Link-visible with auto-generated key\n\
289                 - #private           Author-only (NIP-44 encrypted)\n\
290                 - (no fragment)      Public",
291                fragment
292            );
293        }
294    } else {
295        (url, None, false, false)
296    };
297
298    // Split on first /
299    let (identifier, repo) = url_path
300        .split_once('/')
301        .context("URL must be htree://<identifier>/<repo>")?;
302
303    // Handle repo paths like "repo/subpath" - keep full path as repo name
304    let repo_name = repo.to_string();
305
306    if identifier.is_empty() {
307        bail!("Identifier cannot be empty");
308    }
309    if repo_name.is_empty() {
310        bail!("Repository name cannot be empty");
311    }
312
313    Ok(ParsedUrl {
314        identifier: identifier.to_string(),
315        repo_name,
316        secret_key,
317        is_private,
318        auto_generate_secret,
319    })
320}
321
322/// Generate a new random secret key for private repos
323pub fn generate_secret_key() -> [u8; 32] {
324    let mut key = [0u8; 32];
325    getrandom::fill(&mut key).expect("Failed to generate random bytes");
326    key
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_parse_htree_url_pubkey() {
335        let parsed = parse_htree_url(
336            "htree://a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8/myrepo",
337        )
338        .unwrap();
339        assert_eq!(
340            parsed.identifier,
341            "a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8"
342        );
343        assert_eq!(parsed.repo_name, "myrepo");
344        assert!(parsed.secret_key.is_none());
345    }
346
347    #[test]
348    fn test_parse_htree_url_npub() {
349        let parsed =
350            parse_htree_url("htree://npub1qvmu0aru530g6yu3kmlhw33fh68r75wf3wuml3vk4ekg0p4m4t6s7fuhxx/test")
351                .unwrap();
352        assert!(parsed.identifier.starts_with("npub1"));
353        assert_eq!(parsed.repo_name, "test");
354        assert!(parsed.secret_key.is_none());
355    }
356
357    #[test]
358    fn test_parse_htree_url_petname() {
359        let parsed = parse_htree_url("htree://alice/project").unwrap();
360        assert_eq!(parsed.identifier, "alice");
361        assert_eq!(parsed.repo_name, "project");
362        assert!(parsed.secret_key.is_none());
363    }
364
365    #[test]
366    fn test_parse_htree_url_self() {
367        let parsed = parse_htree_url("htree://self/myrepo").unwrap();
368        assert_eq!(parsed.identifier, "self");
369        assert_eq!(parsed.repo_name, "myrepo");
370        assert!(parsed.secret_key.is_none());
371    }
372
373    #[test]
374    fn test_parse_htree_url_with_subpath() {
375        let parsed = parse_htree_url("htree://test/repo/some/path").unwrap();
376        assert_eq!(parsed.identifier, "test");
377        assert_eq!(parsed.repo_name, "repo/some/path");
378        assert!(parsed.secret_key.is_none());
379    }
380
381    #[test]
382    fn test_parse_htree_url_with_secret() {
383        let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
384        let url = format!("htree://test/repo#k={}", secret_hex);
385        let parsed = parse_htree_url(&url).unwrap();
386        assert_eq!(parsed.identifier, "test");
387        assert_eq!(parsed.repo_name, "repo");
388        assert!(parsed.secret_key.is_some());
389        let key = parsed.secret_key.unwrap();
390        assert_eq!(hex::encode(key), secret_hex);
391    }
392
393    #[test]
394    fn test_parse_htree_url_invalid_secret_length() {
395        // Secret too short
396        let url = "htree://test/repo#k=0123456789abcdef";
397        assert!(parse_htree_url(url).is_err());
398    }
399
400    #[test]
401    fn test_parse_htree_url_invalid_secret_hex() {
402        // Invalid hex characters
403        let url = "htree://test/repo#k=ghij456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
404        assert!(parse_htree_url(url).is_err());
405    }
406
407    #[test]
408    fn test_parse_htree_url_invalid_scheme() {
409        assert!(parse_htree_url("https://example.com/repo").is_err());
410    }
411
412    #[test]
413    fn test_parse_htree_url_no_repo() {
414        assert!(parse_htree_url("htree://pubkey").is_err());
415    }
416
417    #[test]
418    fn test_parse_htree_url_empty_identifier() {
419        assert!(parse_htree_url("htree:///repo").is_err());
420    }
421
422    #[test]
423    fn test_parse_htree_url_colon() {
424        // Some git versions may pass URL with : instead of /
425        let result = parse_htree_url("htree://test:repo");
426        assert!(result.is_err()); // We don't support : syntax
427    }
428
429    #[test]
430    fn test_parse_htree_url_private() {
431        let parsed = parse_htree_url("htree://self/myrepo#private").unwrap();
432        assert_eq!(parsed.identifier, "self");
433        assert_eq!(parsed.repo_name, "myrepo");
434        assert!(parsed.is_private);
435        assert!(parsed.secret_key.is_none());
436    }
437
438    #[test]
439    fn test_parse_htree_url_secret_not_private() {
440        // #k= is link-visible, not private
441        let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
442        let url = format!("htree://test/repo#k={}", secret_hex);
443        let parsed = parse_htree_url(&url).unwrap();
444        assert!(!parsed.is_private);
445        assert!(parsed.secret_key.is_some());
446    }
447
448    #[test]
449    fn test_parse_htree_url_public() {
450        // No fragment = public
451        let parsed = parse_htree_url("htree://test/repo").unwrap();
452        assert!(!parsed.is_private);
453        assert!(parsed.secret_key.is_none());
454        assert!(!parsed.auto_generate_secret);
455    }
456
457    #[test]
458    fn test_parse_htree_url_link_visible_auto() {
459        // #link-visible = auto-generate key
460        let parsed = parse_htree_url("htree://self/myrepo#link-visible").unwrap();
461        assert_eq!(parsed.identifier, "self");
462        assert_eq!(parsed.repo_name, "myrepo");
463        assert!(!parsed.is_private);
464        assert!(parsed.secret_key.is_none()); // Key will be generated at runtime
465        assert!(parsed.auto_generate_secret);
466    }
467
468    #[test]
469    fn test_parse_htree_url_link_visible_explicit_key() {
470        // #k=<hex> = explicit key, not auto-generate
471        let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
472        let url = format!("htree://test/repo#k={}", secret_hex);
473        let parsed = parse_htree_url(&url).unwrap();
474        assert!(parsed.secret_key.is_some());
475        assert!(!parsed.auto_generate_secret); // Not auto-generated
476    }
477
478    #[test]
479    fn test_parse_htree_url_private_not_auto_generate() {
480        // #private is not auto_generate_secret
481        let parsed = parse_htree_url("htree://self/myrepo#private").unwrap();
482        assert!(parsed.is_private);
483        assert!(!parsed.auto_generate_secret);
484    }
485}