Skip to main content

strop_git/
permalink.rs

1//! Permalink remote normalization and URL construction (0001 pillar
2//! 3.3, 0033 finding 1). Everything here is pure — no filesystem, no
3//! environment, no process. SSH aliases come back to the caller as
4//! unresolved data; OpenSSH effective-configuration evaluation lives
5//! in [`crate::ssh`] and is owned IO-worker work, never this module.
6
7use std::borrow::Cow;
8use std::ffi::OsStr;
9use std::path::{Component, Path};
10
11/// A remote whose web identity is fully known: permalink construction
12/// from here is pure.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct WebRemote {
15    /// Scheme plus authority, `"https://github.com"`. HTTP(S) remotes
16    /// keep their full authority — host and port survive, nested
17    /// groups included; SSH-derived bases drop user and SSH port (the
18    /// web port is not the SSH port).
19    pub base: String,
20    /// Repository path under the base with one trailing `.git`
21    /// stripped: `"acme/demo"` — nested paths survive whole.
22    pub repo: String,
23}
24
25/// An SSH endpoint whose effective configuration has not been evaluated.
26/// Any host spelling can be remapped by OpenSSH, including dotted aliases.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct AliasRemote {
29    /// The alias exactly as the remote spells it (validated host
30    /// characters only, so it cannot become an option later).
31    pub alias: String,
32    pub repo: String,
33    pub(crate) user: Option<String>,
34    pub(crate) port: Option<std::num::NonZeroU16>,
35}
36
37impl AliasRemote {
38    pub fn host(&self) -> &str {
39        &self.alias
40    }
41    /// Pure: fold OpenSSH's effective hostname into a web remote.
42    pub fn resolved(&self, hostname: &str) -> Option<WebRemote> {
43        if !is_safe_host(hostname) || (hostname == self.alias && !hostname.contains('.')) {
44            return None;
45        }
46        Some(WebRemote {
47            base: format!("https://{hostname}"),
48            repo: self.repo.clone(),
49        })
50    }
51}
52
53/// The remote a permalink will point at.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum SelectedRemote {
56    Web(WebRemote),
57    Alias(AliasRemote),
58}
59
60/// Parse one remote URL. Pure — no IO of any kind.
61///
62/// - HTTP(S) remotes keep their full authority (host and port) and
63///   their whole repository path, nested or not; userinfo is dropped
64///   so credentials never reach a link.
65/// - Every SSH host is evaluated with OpenSSH; punctuation cannot tell
66///   a literal host from an alias, and Match rules can depend on user/port.
67/// - Everything else — local paths, `git://`, bracketed IPv6,
68///   option-shaped text — is an honest `None`, never a guessed base.
69pub fn parse_remote(url: &str) -> Option<SelectedRemote> {
70    let url = url.trim();
71    if let Some(rest) = url.strip_prefix("ssh://") {
72        let (authority, path) = rest.split_once('/')?;
73        return selected(authority, uri_repo(path)?);
74    }
75    if let Some((scheme, rest)) = url
76        .split_once("://")
77        .filter(|(scheme, _)| matches!(*scheme, "http" | "https"))
78    {
79        let (authority, path) = rest.split_once('/')?;
80        let authority = strip_userinfo(authority);
81        if !safe_authority(authority) {
82            return None;
83        }
84        return Some(SelectedRemote::Web(WebRemote {
85            base: format!("{scheme}://{authority}"),
86            repo: uri_repo(path)?,
87        }));
88    }
89    if url.contains("://") {
90        // git://, file://, svn+ssh:// … — no web home to link to.
91        return None;
92    }
93    // scp-like syntax: [user@]host:repo/path — the colon separates.
94    let (owner, path) = url.split_once(':')?;
95    selected(owner, repo_path(path)?)
96}
97
98/// Pick the permalink remote: upstream > origin > first remaining
99/// (0001 pillar 3.3). The first remote that parses wins; a failure of
100/// the winner surfaces at the caller — never a silent substitute URL.
101pub fn pick_remote(remotes: &[(String, String)]) -> Option<SelectedRemote> {
102    for name in ["upstream", "origin"] {
103        if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
104            if let Some(remote) = parse_remote(url) {
105                return Some(remote);
106            }
107        }
108    }
109    remotes.iter().find_map(|(_, url)| parse_remote(url))
110}
111
112/// The immutable permalink (0001 pillar 3.3): revision pinned to a
113/// commit SHA the caller already resolved, repository and file path
114/// segments percent-encoded from native bytes, GitHub/GitLab `#L` line
115/// anchors.
116pub fn permalink(remote: &WebRemote, sha: &str, path: &Path, lines: (usize, usize)) -> String {
117    let frag = if lines.0 == lines.1 {
118        format!("#L{}", lines.0)
119    } else {
120        format!("#L{}-L{}", lines.0, lines.1)
121    };
122    format!(
123        "{}/{}/blob/{}/{}{frag}",
124        remote.base,
125        encode_repo_path(&remote.repo),
126        sha,
127        encode_path(path),
128    )
129}
130
131/// Hostname/alias shape: ASCII letters, digits, `.`, `_`, `-` only,
132/// never option-shaped (no leading `-`). The same text later rides an
133/// argv and a URL — both stay inert by construction.
134pub(crate) fn is_safe_host(host: &str) -> bool {
135    !host.is_empty()
136        && !host.starts_with('-')
137        && host
138            .bytes()
139            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
140}
141
142/// Userinfo off an authority: `git@host` → `host`. Credentials have no
143/// business in a permalink.
144fn strip_userinfo(authority: &str) -> &str {
145    authority
146        .rsplit_once('@')
147        .map_or(authority, |(_, host)| host)
148}
149
150/// An HTTP(S) authority quoted verbatim into a link: host, or
151fn safe_authority(authority: &str) -> bool {
152    let mut parts = authority.split(':');
153    let host = parts.next().unwrap_or_default();
154    match (parts.next(), parts.next()) {
155        (None, _) => is_safe_host(host),
156        (Some(port), None) => is_safe_host(host) && port.parse::<std::num::NonZeroU16>().is_ok(),
157        // more than one colon is not a plain authority
158        (Some(_), Some(_)) => false,
159    }
160}
161
162/// Host part of an SSH authority: one optional trailing numeric port,
163/// which is dropped — the SSH port is not the web port. Bracketed IPv6
164/// and other exotic authorities are refused: an honest `None` beats a
165/// mangled link.
166fn ssh_host(authority: &str) -> Option<&str> {
167    let host = match authority.split_once(':') {
168        None => authority,
169        Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
170        Some(_) => return None,
171    };
172    is_safe_host(host).then_some(host)
173}
174
175/// Retain transport identity for OpenSSH's effective configuration.
176fn selected(authority: &str, repo: String) -> Option<SelectedRemote> {
177    let (user, authority) = match authority.rsplit_once('@') {
178        Some((user, authority)) if is_safe_host(user) => (Some(user.to_owned()), authority),
179        Some(_) => return None,
180        None => (None, authority),
181    };
182    let host = ssh_host(authority)?;
183    let port = match authority.split_once(':') {
184        Some((_, port)) => Some(port.parse::<std::num::NonZeroU16>().ok()?),
185        None => None,
186    };
187    Some(SelectedRemote::Alias(AliasRemote {
188        alias: host.to_owned(),
189        repo,
190        user,
191        port,
192    }))
193}
194
195/// Repository path under a host: leading separators dropped, exactly
196/// one trailing `.git` stripped (git's own canonical form — a repo
197/// really named `demo.git.git` stays `demo.git`). Empty is no
198/// repository, not a link to the site root.
199fn repo_path(path: &str) -> Option<String> {
200    let path = path.trim_start_matches('/');
201    let path = path.strip_suffix(".git").unwrap_or(path);
202    (!path.is_empty()).then(|| path.to_string())
203}
204
205/// URL paths are already escaped; decode once before the shared segment encoder.
206/// scp syntax stays byte-literal and does not pass through this boundary.
207fn uri_repo(path: &str) -> Option<String> {
208    if path.contains(['?', '#']) {
209        return None;
210    }
211    let source = path.trim_start_matches('/').as_bytes();
212    let mut decoded = Vec::with_capacity(source.len());
213    let mut offset = 0;
214    while offset < source.len() {
215        if source[offset] == b'%' {
216            let digits = std::str::from_utf8(source.get(offset + 1..offset + 3)?).ok()?;
217            decoded.push(u8::from_str_radix(digits, 16).ok()?);
218            offset += 3;
219        } else {
220            decoded.push(source[offset]);
221            offset += 1;
222        }
223    }
224    let mut path = String::from_utf8(decoded).ok()?;
225    if path.ends_with(".git") {
226        path.truncate(path.len() - 4);
227    }
228    (!path.is_empty() && !path.contains('\0')).then_some(path)
229}
230
231const HEX: &[u8; 16] = b"0123456789ABCDEF";
232
233/// Percent-encode one path segment per RFC 3986: unreserved bytes pass
234/// through, everything else escapes — spaces, Unicode and the
235/// non-UTF8 bytes Unix paths carry alike. Separators are re-added by
236/// the caller, so no segment can smuggle structure.
237fn encode_segment(segment: &[u8]) -> String {
238    let mut out = String::with_capacity(segment.len());
239    for &byte in segment {
240        match byte {
241            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
242                out.push(byte as char)
243            }
244            _ => {
245                out.push('%');
246                out.push(HEX[(byte >> 4) as usize] as char);
247                out.push(HEX[(byte & 0x0f) as usize] as char);
248            }
249        }
250    }
251    out
252}
253
254fn segment_bytes(segment: &OsStr) -> Cow<'_, [u8]> {
255    #[cfg(unix)]
256    {
257        use std::os::unix::ffi::OsStrExt;
258        Cow::Borrowed(segment.as_bytes())
259    }
260    #[cfg(not(unix))]
261    {
262        Cow::Owned(segment.to_string_lossy().into_owned().into_bytes())
263    }
264}
265
266/// The location's repo-relative path as URL path segments: native
267/// bytes in — never a lossy spelling — encoded segments out.
268fn encode_path(path: &Path) -> String {
269    path.components()
270        .filter_map(|component| match component {
271            Component::Normal(segment) => Some(encode_segment(&segment_bytes(segment))),
272            _ => None,
273        })
274        .collect::<Vec<_>>()
275        .join("/")
276}
277
278/// A remote's repository path (`"acme/nested/demo"`), encoded segment
279/// by segment so spaces and Unicode survive as themselves.
280fn encode_repo_path(repo: &str) -> String {
281    repo.split('/')
282        .filter(|segment| !segment.is_empty())
283        .map(|segment| encode_segment(segment.as_bytes()))
284        .collect::<Vec<_>>()
285        .join("/")
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn web(url: &str) -> (String, String) {
293        match parse_remote(url) {
294            Some(SelectedRemote::Web(remote)) => (remote.base, remote.repo),
295            Some(SelectedRemote::Alias(remote)) => {
296                let web = remote
297                    .resolved(remote.host())
298                    .expect("literal FQDN in fixture");
299                (web.base, web.repo)
300            }
301            other => panic!("{url} did not parse to a web remote: {other:?}"),
302        }
303    }
304
305    fn alias(url: &str) -> (String, String) {
306        match parse_remote(url) {
307            Some(SelectedRemote::Alias(remote)) => (remote.alias, remote.repo),
308            other => panic!("{url} did not parse to an alias remote: {other:?}"),
309        }
310    }
311
312    /// The reviewer's defect (0033 finding 1): the full FQDN authority
313    /// and the whole repository path — nested or not — must survive.
314    #[test]
315    fn https_authority_and_nested_repo_path_survive() {
316        assert_eq!(
317            web("https://bbgithub.dev.bloomberg.com/acme/demo.git"),
318            (
319                "https://bbgithub.dev.bloomberg.com".to_string(),
320                "acme/demo".to_string()
321            )
322        );
323        assert_eq!(
324            web("https://bbgithub.dev.bloomberg.com/acme/nested/demo.git"),
325            (
326                "https://bbgithub.dev.bloomberg.com".to_string(),
327                "acme/nested/demo".to_string()
328            )
329        );
330        // Explicit schemes and ports remain part of the destination.
331        assert_eq!(
332            web("https://gitea.internal:3000/team/sub/project.git"),
333            (
334                "https://gitea.internal:3000".to_string(),
335                "team/sub/project".to_string()
336            )
337        );
338        assert_eq!(
339            web("http://gitea.internal/team/proj"),
340            ("http://gitea.internal".to_string(), "team/proj".to_string())
341        );
342        // credentials never reach a link
343        assert_eq!(
344            web("https://oauth2:tok@gitlab.example.com/group/proj.git"),
345            (
346                "https://gitlab.example.com".to_string(),
347                "group/proj".to_string()
348            )
349        );
350    }
351
352    /// The reviewer table, verbatim, with the exact identity each form
353    /// must produce.
354    #[test]
355    fn reviewer_table_identities() {
356        assert_eq!(
357            web("https://github.com/acme/demo.git"),
358            ("https://github.com".to_string(), "acme/demo".to_string())
359        );
360        assert_eq!(
361            web("ssh://git@github.com/acme/demo.git"),
362            ("https://github.com".to_string(), "acme/demo".to_string())
363        );
364        assert_eq!(
365            web("git@github.com:acme/demo"),
366            ("https://github.com".to_string(), "acme/demo".to_string())
367        );
368        assert_eq!(
369            web("git@bbgithub.dev.bloomberg.com:acme/demo.git"),
370            (
371                "https://bbgithub.dev.bloomberg.com".to_string(),
372                "acme/demo".to_string()
373            )
374        );
375        assert_eq!(
376            web("https://bbgithub.dev.bloomberg.com/acme/demo.git"),
377            (
378                "https://bbgithub.dev.bloomberg.com".to_string(),
379                "acme/demo".to_string()
380            )
381        );
382        // the SSH port is not the web port
383        assert_eq!(
384            web("ssh://git@gitlab.example.com:2222/team/repo.git"),
385            (
386                "https://gitlab.example.com".to_string(),
387                "team/repo".to_string()
388            )
389        );
390    }
391
392    /// A dotless SSH host is an alias: unresolved data, never a bare
393    /// guess from a config parser that never ran.
394    #[test]
395    fn dotless_ssh_hosts_are_unresolved_aliases() {
396        assert_eq!(
397            alias("bbgithub:acme/demo.git"),
398            ("bbgithub".to_string(), "acme/demo".to_string())
399        );
400        assert_eq!(
401            alias("git@bbgithub:acme/demo.git"),
402            ("bbgithub".to_string(), "acme/demo".to_string())
403        );
404        assert_eq!(
405            alias("ssh://git@bbgithub/acme/demo.git"),
406            ("bbgithub".to_string(), "acme/demo".to_string())
407        );
408        assert_eq!(
409            alias("ssh://bb:2222/team/repo.git"),
410            ("bb".to_string(), "team/repo".to_string())
411        );
412        // the resolved fold is pure: effective hostname in, web remote out
413        let resolved = AliasRemote {
414            alias: "bbgithub".to_string(),
415            repo: "acme/demo".to_string(),
416            user: None,
417            port: None,
418        }
419        .resolved("bbgithub.dev.bloomberg.com")
420        .unwrap();
421        assert_eq!(resolved.base, "https://bbgithub.dev.bloomberg.com");
422        assert_eq!(resolved.repo, "acme/demo");
423    }
424
425    /// Refusals: local paths, other schemes, option-shaped text and
426    /// empty repositories produce `None`, not a dead URL.
427    #[test]
428    fn unsupported_urls_refuse_instead_of_guessing() {
429        for url in [
430            "not a url",
431            "/srv/git/repo.git",
432            "../repo",
433            "file:///srv/repo.git",
434            "git://github.com/acme/demo.git",
435            "svn+ssh://host/team/repo",
436            "https://host/",
437            "https://host",
438            "git@host:",
439            "-oProxyCommand=evil:org/repo",
440            "git@-flag:org/repo",
441            "ssh://git@[::1]/repo",
442            "ssh://git@host:notaport/repo",
443            "ssh://git@host",
444        ] {
445            assert!(parse_remote(url).is_none(), "should refuse: {url}");
446        }
447    }
448
449    /// `.git` is stripped once — a repository genuinely named
450    /// `demo.git.git` keeps its identity as `demo.git`.
451    #[test]
452    fn git_suffix_strips_once() {
453        assert_eq!(
454            web("https://host/acme/demo.git.git"),
455            ("https://host".to_string(), "acme/demo.git".to_string())
456        );
457    }
458
459    /// Priority is upstream > origin > first remaining, and the winner
460    /// is chosen once: an alias stays an alias, never a substitute.
461    #[test]
462    fn pick_remote_prefers_upstream_then_origin() {
463        let remote = |name: &str, url: &str| (name.to_string(), url.to_string());
464        let picked = |remotes: &[(String, String)]| pick_remote(remotes);
465        assert_eq!(
466            picked(&[
467                remote("origin", "https://gitlab.com/o/r.git"),
468                remote("upstream", "https://github.com/a/b.git"),
469            ]),
470            Some(SelectedRemote::Web(WebRemote {
471                base: "https://github.com".to_string(),
472                repo: "a/b".to_string(),
473            }))
474        );
475        // an unparseable upstream falls through to origin
476        assert!(matches!(
477            picked(&[
478                remote("upstream", "/local/x"),
479                remote("origin", "git@github.com:o/r.git"),
480            ]),
481            Some(SelectedRemote::Alias(_))
482        ));
483        // the winner's shape is kept: an alias upstream is not replaced
484        // by origin's ready URL
485        assert!(matches!(
486            picked(&[
487                remote("origin", "https://gitlab.com/o/r.git"),
488                remote("upstream", "git@bb:acme/demo.git"),
489            ]),
490            Some(SelectedRemote::Alias(_))
491        ));
492        assert!(picked(&[remote("origin", "/local/x")]).is_none());
493        assert!(matches!(
494            picked(&[remote("other", "https://gitlab.com/o/r.git")]),
495            Some(SelectedRemote::Web(_))
496        ));
497    }
498
499    /// The built link: SHA-pinned, percent-encoded segments, `#L`
500    /// anchors.
501    #[test]
502    fn permalink_pins_sha_and_encodes_segments() {
503        let github = WebRemote {
504            base: "https://github.com".to_string(),
505            repo: "stropdev/strop".to_string(),
506        };
507        assert_eq!(
508            permalink(&github, "abc123", Path::new("f.rs"), (2, 2)),
509            "https://github.com/stropdev/strop/blob/abc123/f.rs#L2"
510        );
511        assert_eq!(
512            permalink(&github, "abc123", Path::new("src/lib.rs"), (1, 3)),
513            "https://github.com/stropdev/strop/blob/abc123/src/lib.rs#L1-L3"
514        );
515        // spaces, Unicode and repository segments encode; separators stay
516        assert_eq!(
517            permalink(&github, "abc123", Path::new("src/sp ace/日本語.rs"), (1, 1)),
518            "https://github.com/stropdev/strop/blob/abc123/src/sp%20ace/%E6%97%A5%E6%9C%AC%E8%AA%9E.rs#L1"
519        );
520        let spaced = WebRemote {
521            base: "https://host".to_string(),
522            repo: "my repo/x".to_string(),
523        };
524        assert_eq!(
525            permalink(&spaced, "abc", Path::new("f.rs"), (1, 1)),
526            "https://host/my%20repo/x/blob/abc/f.rs#L1"
527        );
528    }
529
530    /// Unix filenames may be non-UTF8: their bytes percent-encode, no
531    /// lossy spelling ever reaches the link.
532    #[cfg(unix)]
533    #[test]
534    fn permalink_percent_encodes_non_utf8_path_bytes() {
535        use std::os::unix::ffi::OsStrExt;
536        let github = WebRemote {
537            base: "https://github.com".to_string(),
538            repo: "stropdev/strop".to_string(),
539        };
540        let path = Path::new(std::ffi::OsStr::from_bytes(b"src/\xff\xfe.rs"));
541        assert_eq!(
542            permalink(&github, "abc", path, (1, 1)),
543            "https://github.com/stropdev/strop/blob/abc/src/%FF%FE.rs#L1"
544        );
545    }
546}