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//! - **Unlisted**: 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//! Use `htree://npub/repo#k=<secret>` for unlisted/link-visible repos.
24
25use anyhow::{bail, Context, Result};
26use nostr_sdk::ToBech32;
27use std::io::{BufRead, Write};
28use tracing::{debug, info, warn};
29
30mod git;
31mod helper;
32mod nostr_client;
33
34use hashtree_config::Config;
35use helper::RemoteHelper;
36use nostr_client::resolve_identity;
37
38/// Entry point for the git remote helper
39/// Call this from main() to run the helper
40pub fn main_entry() {
41    // Install TLS crypto provider for reqwest/rustls
42    let _ = rustls::crypto::ring::default_provider().install_default();
43
44    if let Err(e) = run() {
45        eprintln!("Error: {:#}", e);
46        std::process::exit(1);
47    }
48}
49
50fn run() -> Result<()> {
51    // Suppress broken pipe panics - git may close the pipe early
52    #[cfg(unix)]
53    {
54        unsafe {
55            libc::signal(libc::SIGPIPE, libc::SIG_DFL);
56        }
57    }
58
59    // Initialize logging - only show errors by default
60    // Set RUST_LOG=debug for verbose output
61    tracing_subscriber::fmt()
62        .with_env_filter(
63            tracing_subscriber::EnvFilter::from_default_env()
64                .add_directive("git_remote_htree=error".parse().unwrap())
65                .add_directive("nostr_relay_pool=off".parse().unwrap()),
66        )
67        .with_writer(std::io::stderr)
68        .init();
69
70    let args: Vec<String> = std::env::args().collect();
71    debug!("git-remote-htree called with args: {:?}", args);
72
73    // Git calls us as: git-remote-htree <remote-name> <url>
74    if args.len() < 3 {
75        bail!("Usage: git-remote-htree <remote-name> <url>");
76    }
77
78    let remote_name = &args[1];
79    let url = &args[2];
80
81    info!("Remote: {}, URL: {}", remote_name, url);
82
83    // Parse URL: htree://<identifier>/<repo-name>#k=<secret>
84    let parsed = parse_htree_url(url)?;
85    let identifier = parsed.identifier;
86    let repo_name = parsed.repo_name;
87    let url_secret = parsed.secret_key; // Encryption secret from URL fragment
88
89    if url_secret.is_some() {
90        info!("Private repo mode: using secret key from URL");
91    }
92
93    // Resolve identifier to pubkey
94    // If "self" is used and no keys exist, auto-generate
95    let (pubkey, signing_key) = match resolve_identity(&identifier) {
96        Ok(result) => result,
97        Err(e) => {
98            // If resolution failed and user intended "self", suggest using htree://self/repo
99            warn!("Failed to resolve identity '{}': {}", identifier, e);
100            info!("Tip: Use htree://self/<repo> to auto-generate identity on first use");
101            return Err(e);
102        }
103    };
104
105    if signing_key.is_some() {
106        debug!("Found signing key for {}", identifier);
107    } else {
108        debug!("No signing key for {} (read-only)", identifier);
109    }
110
111    // Print npub for reference
112    if let Ok(pk_bytes) = hex::decode(&pubkey) {
113        if pk_bytes.len() == 32 {
114            if let Ok(pk) = nostr_sdk::PublicKey::from_slice(&pk_bytes) {
115                if let Ok(npub) = pk.to_bech32() {
116                    info!("Using identity: {}", npub);
117                }
118            }
119        }
120    }
121
122    // Load config
123    let config = Config::load_or_default();
124    debug!("Loaded config with {} read servers, {} write servers",
125           config.blossom.read_servers.len(),
126           config.blossom.write_servers.len());
127
128    // Create helper and run protocol
129    let mut helper = RemoteHelper::new(&pubkey, &repo_name, signing_key, url_secret, config)?;
130
131    // Read commands from stdin, write responses to stdout
132    let stdin = std::io::stdin();
133    let stdout = std::io::stdout();
134    let mut stdout = stdout.lock();
135
136    for line in stdin.lock().lines() {
137        let line = match line {
138            Ok(l) => l,
139            Err(e) => {
140                if e.kind() == std::io::ErrorKind::BrokenPipe {
141                    break;
142                }
143                return Err(e.into());
144            }
145        };
146
147        debug!("< {}", line);
148
149        match helper.handle_command(&line) {
150            Ok(Some(responses)) => {
151                for response in responses {
152                    debug!("> {}", response);
153                    if let Err(e) = writeln!(stdout, "{}", response) {
154                        if e.kind() == std::io::ErrorKind::BrokenPipe {
155                            break;
156                        }
157                        return Err(e.into());
158                    }
159                }
160                if let Err(e) = stdout.flush() {
161                    if e.kind() == std::io::ErrorKind::BrokenPipe {
162                        break;
163                    }
164                    return Err(e.into());
165                }
166            }
167            Ok(None) => {}
168            Err(e) => {
169                warn!("Command error: {}", e);
170                // Exit on error to avoid hanging
171                return Err(e);
172            }
173        }
174
175        if helper.should_exit() {
176            break;
177        }
178    }
179
180    Ok(())
181}
182
183/// Parsed htree URL components
184pub struct ParsedUrl {
185    pub identifier: String,
186    pub repo_name: String,
187    /// Secret key from #k=<hex> fragment (for private repos)
188    pub secret_key: Option<[u8; 32]>,
189}
190
191/// Parse htree:// URL into components
192/// Supports: htree://identifier/repo#k=<hex> for private repos
193fn parse_htree_url(url: &str) -> Result<ParsedUrl> {
194    let url = url
195        .strip_prefix("htree://")
196        .context("URL must start with htree://")?;
197
198    // Split off fragment (#k=secret) if present
199    let (url_path, secret_key) = if let Some((path, fragment)) = url.split_once('#') {
200        let key = if let Some(key_hex) = fragment.strip_prefix("k=") {
201            let bytes = hex::decode(key_hex)
202                .context("Invalid secret key hex in URL fragment")?;
203            if bytes.len() != 32 {
204                bail!("Secret key must be 32 bytes (64 hex chars)");
205            }
206            let mut key = [0u8; 32];
207            key.copy_from_slice(&bytes);
208            Some(key)
209        } else {
210            None
211        };
212        (path, key)
213    } else {
214        (url, None)
215    };
216
217    // Split on first /
218    let (identifier, repo) = url_path
219        .split_once('/')
220        .context("URL must be htree://<identifier>/<repo>")?;
221
222    // Handle repo paths like "repo/subpath" - keep full path as repo name
223    let repo_name = repo.to_string();
224
225    if identifier.is_empty() {
226        bail!("Identifier cannot be empty");
227    }
228    if repo_name.is_empty() {
229        bail!("Repository name cannot be empty");
230    }
231
232    Ok(ParsedUrl {
233        identifier: identifier.to_string(),
234        repo_name,
235        secret_key,
236    })
237}
238
239/// Generate a new random secret key for private repos
240pub fn generate_secret_key() -> [u8; 32] {
241    let mut key = [0u8; 32];
242    getrandom::fill(&mut key).expect("Failed to generate random bytes");
243    key
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_parse_htree_url_pubkey() {
252        let parsed = parse_htree_url(
253            "htree://a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8/myrepo",
254        )
255        .unwrap();
256        assert_eq!(
257            parsed.identifier,
258            "a9a91ed5f1c405618f63fdd393f9055ab8bac281102cff6b1ac3c74094562dd8"
259        );
260        assert_eq!(parsed.repo_name, "myrepo");
261        assert!(parsed.secret_key.is_none());
262    }
263
264    #[test]
265    fn test_parse_htree_url_npub() {
266        let parsed =
267            parse_htree_url("htree://npub1qvmu0aru530g6yu3kmlhw33fh68r75wf3wuml3vk4ekg0p4m4t6s7fuhxx/test")
268                .unwrap();
269        assert!(parsed.identifier.starts_with("npub1"));
270        assert_eq!(parsed.repo_name, "test");
271        assert!(parsed.secret_key.is_none());
272    }
273
274    #[test]
275    fn test_parse_htree_url_petname() {
276        let parsed = parse_htree_url("htree://alice/project").unwrap();
277        assert_eq!(parsed.identifier, "alice");
278        assert_eq!(parsed.repo_name, "project");
279        assert!(parsed.secret_key.is_none());
280    }
281
282    #[test]
283    fn test_parse_htree_url_self() {
284        let parsed = parse_htree_url("htree://self/myrepo").unwrap();
285        assert_eq!(parsed.identifier, "self");
286        assert_eq!(parsed.repo_name, "myrepo");
287        assert!(parsed.secret_key.is_none());
288    }
289
290    #[test]
291    fn test_parse_htree_url_with_subpath() {
292        let parsed = parse_htree_url("htree://test/repo/some/path").unwrap();
293        assert_eq!(parsed.identifier, "test");
294        assert_eq!(parsed.repo_name, "repo/some/path");
295        assert!(parsed.secret_key.is_none());
296    }
297
298    #[test]
299    fn test_parse_htree_url_with_secret() {
300        let secret_hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
301        let url = format!("htree://test/repo#k={}", secret_hex);
302        let parsed = parse_htree_url(&url).unwrap();
303        assert_eq!(parsed.identifier, "test");
304        assert_eq!(parsed.repo_name, "repo");
305        assert!(parsed.secret_key.is_some());
306        let key = parsed.secret_key.unwrap();
307        assert_eq!(hex::encode(key), secret_hex);
308    }
309
310    #[test]
311    fn test_parse_htree_url_invalid_secret_length() {
312        // Secret too short
313        let url = "htree://test/repo#k=0123456789abcdef";
314        assert!(parse_htree_url(url).is_err());
315    }
316
317    #[test]
318    fn test_parse_htree_url_invalid_secret_hex() {
319        // Invalid hex characters
320        let url = "htree://test/repo#k=ghij456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
321        assert!(parse_htree_url(url).is_err());
322    }
323
324    #[test]
325    fn test_parse_htree_url_invalid_scheme() {
326        assert!(parse_htree_url("https://example.com/repo").is_err());
327    }
328
329    #[test]
330    fn test_parse_htree_url_no_repo() {
331        assert!(parse_htree_url("htree://pubkey").is_err());
332    }
333
334    #[test]
335    fn test_parse_htree_url_empty_identifier() {
336        assert!(parse_htree_url("htree:///repo").is_err());
337    }
338
339    #[test]
340    fn test_parse_htree_url_colon() {
341        // Some git versions may pass URL with : instead of /
342        let result = parse_htree_url("htree://test:repo");
343        assert!(result.is_err()); // We don't support : syntax
344    }
345}