Skip to main content

vgi_core/
resource.rs

1//! Forge-qualified trust-tuple resources.
2//!
3//! A resource names the forge first, then the path on it:
4//!
5//! ```text
6//! resource   = forge-host "/" segment *( "/" segment )
7//! forge-host = lowercased DNS host of the forge: github.com, a GHES host,
8//!              codeberg.org, a self-hosted git.example.org, or localhost
9//! segment    = lowercased [a-z0-9._-]+, never "." or ".."
10//! ```
11//!
12//! `github.com/acme` and `github.com/acme/widgets` are resources;
13//! `acme/widgets` is not. The forge is explicit because `github.com/acme` and
14//! `codeberg.org/acme` may belong to different people, and a grant that
15//! silently assumed one of them would be a grant to whoever holds the other.
16//!
17//! Normalisation is deliberately narrow: ASCII case is folded (GitHub and
18//! Forgejo owners and repo names are case-insensitive, so `Acme/Widgets` and
19//! `acme/widgets` are one repository and must be one resource), and nothing
20//! else is repaired. A scheme, a trailing slash, a `.git` suffix or an empty
21//! segment is refused with a message that says what to write instead, rather
22//! than quietly rewritten — `resource` is what scopes a signer, and an input
23//! that needed guessing at is one to show back to the operator.
24//!
25//! How many path segments a forge allows is the forge's rule, not the
26//! grammar's: GitHub and Forgejo have exactly an owner and optionally a repo,
27//! while a forge with nested groups keeps its full path. Callers that know
28//! their forge pass that bound to [`normalize_resource_with_depth`].
29
30use std::fmt;
31
32/// Longest resource accepted, in bytes. Far above any real forge path
33/// (GitHub: 39-byte owners, 100-byte repo names) while keeping a hostile
34/// input from turning into an unbounded registry key.
35pub const MAX_RESOURCE_LEN: usize = 512;
36
37/// Most path segments (after the host) [`normalize_resource`] accepts when the
38/// caller states no forge-specific bound. GitLab allows 20 levels of subgroup
39/// under a top-level group, so this is that plus the project.
40pub const MAX_PATH_SEGMENTS: usize = 21;
41
42/// Why a string is not a valid forge-qualified resource.
43///
44/// The `Display` form names the offending input and suggests the fix, so it
45/// can be shown to an operator as-is.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ResourceError {
48    input: String,
49    kind: ResourceErrorKind,
50}
51
52/// The specific rule a resource broke.
53#[derive(Debug, Clone, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum ResourceErrorKind {
56    /// Nothing but whitespace, or nothing at all.
57    Empty,
58    /// Longer than [`MAX_RESOURCE_LEN`].
59    TooLong,
60    /// Contains whitespace or a control character.
61    Whitespace,
62    /// Contains a non-ASCII character (hosts must be given in punycode).
63    NonAscii,
64    /// Starts with a URL scheme such as `https://`.
65    HasScheme,
66    /// The first segment is not a forge host — the `owner/repo` form.
67    MissingForgeHost,
68    /// The first segment looks like a host but is not a valid one.
69    InvalidHost,
70    /// A host with a `:port`. Resources name a forge by host alone.
71    HasPort,
72    /// Only a host, with no owner after it.
73    MissingOwner,
74    /// Two slashes in a row, or a leading/trailing slash.
75    EmptySegment,
76    /// A `.` or `..` segment.
77    DotSegment,
78    /// A character outside `[a-z0-9._-]` in a path segment.
79    InvalidCharacter(char),
80    /// More path segments than the forge allows.
81    TooManySegments {
82        /// The bound that was exceeded.
83        max: usize,
84    },
85}
86
87impl ResourceError {
88    fn new(input: &str, kind: ResourceErrorKind) -> Self {
89        // Keep enough of a hostile input to be recognisable in a message, not
90        // the whole of it.
91        let input = if input.len() > 80 {
92            let mut end = 80;
93            while !input.is_char_boundary(end) {
94                end -= 1;
95            }
96            format!("{}…", &input[..end])
97        } else {
98            input.to_string()
99        };
100        Self { input, kind }
101    }
102
103    /// The rule that was broken.
104    pub fn kind(&self) -> &ResourceErrorKind {
105        &self.kind
106    }
107
108    /// The input that was rejected (truncated if it was long).
109    pub fn input(&self) -> &str {
110        &self.input
111    }
112
113    /// The message, naming the value `what` (`--resource`, `resource
114    /// derived from GITHUB_REPOSITORY`, …). [`fmt::Display`] is
115    /// `describe("resource")`.
116    pub fn describe(&self, what: &str) -> String {
117        let v = &self.input;
118        let e = "`<forge-host>/<owner>[/<repo>]`";
119        match &self.kind {
120            ResourceErrorKind::Empty => {
121                format!("{what} is empty; expected {e}, e.g. `github.com/acme/widgets`")
122            }
123            ResourceErrorKind::TooLong => {
124                format!("{what} `{v}` is longer than {MAX_RESOURCE_LEN} bytes")
125            }
126            ResourceErrorKind::Whitespace => format!(
127                "{what} `{v}` contains whitespace or a control character; a resource is {e} \
128                 with no spaces"
129            ),
130            ResourceErrorKind::NonAscii => format!(
131                "{what} `{v}` contains a non-ASCII character; forge hosts are written in \
132                 punycode and owner/repo names are ASCII"
133            ),
134            ResourceErrorKind::HasScheme => format!(
135                "{what} `{v}` is a URL, not a resource; did you mean `{}`?",
136                suggest_without_scheme(v)
137            ),
138            ResourceErrorKind::MissingForgeHost => {
139                let bare = v.trim_matches('/').to_ascii_lowercase();
140                format!(
141                    "{what} `{v}` is not forge-qualified (expected {e}); prefix the forge host, \
142                     e.g. `github.com/{bare}` or `codeberg.org/{bare}`"
143                )
144            }
145            ResourceErrorKind::InvalidHost => format!(
146                "{what} `{v}` starts with an invalid forge host; a host is dot-separated labels \
147                 of [a-z0-9-] (e.g. `github.com`, `git.example.org`) or `localhost`"
148            ),
149            ResourceErrorKind::HasPort => format!(
150                "{what} `{v}` carries a port; a resource names the forge by host alone — did you \
151                 mean `{}`?",
152                suggest_without_port(v)
153            ),
154            ResourceErrorKind::MissingOwner => {
155                let host = v.trim_end_matches('/').to_ascii_lowercase();
156                format!(
157                    "{what} `{v}` names a forge but no owner; e.g. `{host}/acme` or \
158                     `{host}/acme/widgets`"
159                )
160            }
161            ResourceErrorKind::EmptySegment => format!(
162                "{what} `{v}` has an empty path segment (a doubled, leading or trailing `/`); \
163                 did you mean `{}`?",
164                suggest_collapsed(v)
165            ),
166            ResourceErrorKind::DotSegment => {
167                format!("{what} `{v}` has a `.` or `..` segment; name the owner and repo directly")
168            }
169            ResourceErrorKind::InvalidCharacter(c) => format!(
170                "{what} `{v}` contains `{}`; owner and repo segments may only contain letters, \
171                 digits, `.`, `_` and `-`",
172                c.escape_default()
173            ),
174            ResourceErrorKind::TooManySegments { max } => format!(
175                "{what} `{v}` has too many segments for this forge: at most {max} after the host \
176                 (`<forge-host>/<owner>/<repo>`)"
177            ),
178        }
179    }
180}
181
182impl fmt::Display for ResourceError {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        f.write_str(&self.describe("resource"))
185    }
186}
187
188impl std::error::Error for ResourceError {}
189
190/// Normalise a forge-qualified resource, allowing up to
191/// [`MAX_PATH_SEGMENTS`] path segments after the host.
192///
193/// Returns the canonical, lowercased form. See the module docs for the
194/// grammar and why only case is folded.
195pub fn normalize_resource(raw: &str) -> Result<String, ResourceError> {
196    normalize_resource_with_depth(raw, MAX_PATH_SEGMENTS)
197}
198
199/// Normalise a forge-qualified resource with at most `max_path_segments`
200/// segments after the host — `2` for GitHub and Forgejo (`owner/repo`).
201pub fn normalize_resource_with_depth(
202    raw: &str,
203    max_path_segments: usize,
204) -> Result<String, ResourceError> {
205    let err = |kind| ResourceError::new(raw, kind);
206
207    if raw.trim().is_empty() {
208        return Err(err(ResourceErrorKind::Empty));
209    }
210    if raw.len() > MAX_RESOURCE_LEN {
211        return Err(err(ResourceErrorKind::TooLong));
212    }
213    if raw.chars().any(|c| c.is_whitespace() || c.is_control()) {
214        return Err(err(ResourceErrorKind::Whitespace));
215    }
216    if !raw.is_ascii() {
217        return Err(err(ResourceErrorKind::NonAscii));
218    }
219    if raw.contains("://") {
220        return Err(err(ResourceErrorKind::HasScheme));
221    }
222
223    let lowered = raw.to_ascii_lowercase();
224    let segments: Vec<&str> = lowered.split('/').collect();
225    let host = segments[0];
226
227    // The host decides whether this is the legacy `owner/repo` form, so check
228    // it before complaining about anything after it.
229    if host.is_empty() {
230        // A leading slash: in front of a host it is just an empty segment,
231        // in front of `acme/widgets` it is the legacy slug.
232        return Err(err(match segments.get(1) {
233            Some(next) if !next.is_empty() && !looks_like_host(next) => {
234                ResourceErrorKind::MissingForgeHost
235            }
236            _ => ResourceErrorKind::EmptySegment,
237        }));
238    }
239    if let Some((name, _port)) = host.split_once(':') {
240        if looks_like_host(name) {
241            return Err(err(ResourceErrorKind::HasPort));
242        }
243        return Err(err(ResourceErrorKind::InvalidHost));
244    }
245    if !looks_like_host(host) {
246        return Err(err(if host.is_empty() {
247            ResourceErrorKind::EmptySegment
248        } else {
249            ResourceErrorKind::MissingForgeHost
250        }));
251    }
252    if !is_valid_host(host) {
253        return Err(err(ResourceErrorKind::InvalidHost));
254    }
255
256    let path = &segments[1..];
257    if path.is_empty() || (path.len() == 1 && path[0].is_empty()) {
258        return Err(err(ResourceErrorKind::MissingOwner));
259    }
260    if path.iter().any(|s| s.is_empty()) {
261        return Err(err(ResourceErrorKind::EmptySegment));
262    }
263    if path.iter().any(|s| *s == "." || *s == "..") {
264        return Err(err(ResourceErrorKind::DotSegment));
265    }
266    if let Some(c) = path
267        .iter()
268        .flat_map(|s| s.chars())
269        .find(|c| !is_segment_char(*c))
270    {
271        return Err(err(ResourceErrorKind::InvalidCharacter(c)));
272    }
273    if path.len() > max_path_segments {
274        return Err(err(ResourceErrorKind::TooManySegments {
275            max: max_path_segments,
276        }));
277    }
278
279    Ok(lowered)
280}
281
282/// Segment-prefix containment of two normalised resources: `scope` contains
283/// `resource` when it is equal to it or a whole-segment prefix of it.
284///
285/// `github.com/acme` contains `github.com/acme/widgets`; it does not contain
286/// `github.com/acme-labs/x` (a byte prefix, not a segment prefix) or
287/// `codeberg.org/acme/widgets` (another forge). Both arguments must already be
288/// normalised — this compares bytes and does not fold case.
289pub fn resource_contains(scope: &str, resource: &str) -> bool {
290    match resource.strip_prefix(scope) {
291        Some(rest) => rest.is_empty() || rest.starts_with('/'),
292        None => false,
293    }
294}
295
296/// Whether a first segment is meant as a host: a dotted name or `localhost`.
297/// `acme` in `acme/widgets` is not, which is how the legacy form is caught.
298fn looks_like_host(segment: &str) -> bool {
299    segment.contains('.') || segment == "localhost"
300}
301
302/// RFC 1123 host: dot-separated labels of `[a-z0-9-]`, 1–63 bytes each, not
303/// starting or ending with `-`.
304fn is_valid_host(host: &str) -> bool {
305    host.len() <= 253
306        && host.split('.').all(|label| {
307            !label.is_empty()
308                && label.len() <= 63
309                && !label.starts_with('-')
310                && !label.ends_with('-')
311                && label
312                    .bytes()
313                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
314        })
315}
316
317fn is_segment_char(c: char) -> bool {
318    c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')
319}
320
321fn suggest_without_scheme(input: &str) -> String {
322    let rest = input.split_once("://").map_or(input, |(_, rest)| rest);
323    let rest = rest.trim_end_matches('/');
324    rest.strip_suffix(".git")
325        .unwrap_or(rest)
326        .to_ascii_lowercase()
327}
328
329fn suggest_without_port(input: &str) -> String {
330    let lowered = input.to_ascii_lowercase();
331    match lowered.split_once('/') {
332        Some((host, rest)) => {
333            let host = host.split_once(':').map_or(host, |(h, _)| h);
334            format!("{host}/{rest}")
335        }
336        None => lowered,
337    }
338}
339
340fn suggest_collapsed(input: &str) -> String {
341    input
342        .to_ascii_lowercase()
343        .split('/')
344        .filter(|s| !s.is_empty())
345        .collect::<Vec<_>>()
346        .join("/")
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    fn kind(raw: &str) -> ResourceErrorKind {
354        normalize_resource_with_depth(raw, 2)
355            .expect_err(raw)
356            .kind()
357            .clone()
358    }
359
360    #[test]
361    fn canonical_resources_pass_through() {
362        for ok in [
363            "github.com/acme",
364            "github.com/acme/widgets",
365            "codeberg.org/acme/widgets",
366            "git.example.org/acme/.github",
367            "ghe.corp.example/team-a/repo_1.x",
368            "localhost/acme/widgets",
369        ] {
370            assert_eq!(normalize_resource_with_depth(ok, 2).as_deref(), Ok(ok));
371        }
372    }
373
374    #[test]
375    fn case_is_folded_and_nothing_else() {
376        assert_eq!(
377            normalize_resource("GitHub.com/Acme/Widgets").as_deref(),
378            Ok("github.com/acme/widgets")
379        );
380    }
381
382    #[test]
383    fn the_legacy_owner_repo_form_is_refused_with_a_suggestion() {
384        let err = normalize_resource_with_depth("Acme/Widgets", 2).unwrap_err();
385        assert_eq!(err.kind(), &ResourceErrorKind::MissingForgeHost);
386        assert!(err.to_string().contains("github.com/acme/widgets"), "{err}");
387        assert_eq!(kind("acme"), ResourceErrorKind::MissingForgeHost);
388        assert_eq!(kind("/acme/widgets"), ResourceErrorKind::MissingForgeHost);
389    }
390
391    #[test]
392    fn urls_are_refused_with_the_bare_form_suggested() {
393        let err =
394            normalize_resource_with_depth("https://github.com/Acme/widgets.git", 2).unwrap_err();
395        assert_eq!(err.kind(), &ResourceErrorKind::HasScheme);
396        assert!(
397            err.to_string().contains("`github.com/acme/widgets`"),
398            "{err}"
399        );
400    }
401
402    #[test]
403    fn empty_and_dot_segments_are_refused() {
404        assert_eq!(kind("github.com//widgets"), ResourceErrorKind::EmptySegment);
405        assert_eq!(kind("github.com/acme/"), ResourceErrorKind::EmptySegment);
406        assert_eq!(kind("/github.com/acme"), ResourceErrorKind::EmptySegment);
407        assert_eq!(kind("github.com/acme/.."), ResourceErrorKind::DotSegment);
408        assert_eq!(kind("github.com/./acme"), ResourceErrorKind::DotSegment);
409        let err = normalize_resource_with_depth("github.com//acme//x", 2).unwrap_err();
410        assert!(err.to_string().contains("`github.com/acme/x`"), "{err}");
411    }
412
413    #[test]
414    fn a_host_alone_asks_for_an_owner() {
415        assert_eq!(kind("github.com"), ResourceErrorKind::MissingOwner);
416        assert_eq!(kind("github.com/"), ResourceErrorKind::MissingOwner);
417    }
418
419    #[test]
420    fn hosts_are_checked() {
421        assert_eq!(kind("github.com:443/acme"), ResourceErrorKind::HasPort);
422        assert_eq!(kind("-bad.example/acme"), ResourceErrorKind::InvalidHost);
423        assert_eq!(kind("bad..example/acme"), ResourceErrorKind::InvalidHost);
424        assert_eq!(kind("git_hub.com/acme"), ResourceErrorKind::InvalidHost);
425        let err = normalize_resource_with_depth("GitHub.com:8443/acme/x", 2).unwrap_err();
426        assert!(err.to_string().contains("`github.com/acme/x`"), "{err}");
427    }
428
429    #[test]
430    fn odd_characters_are_refused() {
431        assert_eq!(kind("github.com/ac me"), ResourceErrorKind::Whitespace);
432        assert_eq!(kind(" github.com/acme"), ResourceErrorKind::Whitespace);
433        assert_eq!(kind("github.com/acmé"), ResourceErrorKind::NonAscii);
434        assert_eq!(
435            kind("github.com/acme/w%2e"),
436            ResourceErrorKind::InvalidCharacter('%')
437        );
438        assert_eq!(
439            kind("github.com/acme@x"),
440            ResourceErrorKind::InvalidCharacter('@')
441        );
442        assert_eq!(kind(""), ResourceErrorKind::Empty);
443        assert_eq!(
444            kind(&format!("github.com/{}", "a".repeat(600))),
445            ResourceErrorKind::TooLong
446        );
447    }
448
449    #[test]
450    fn depth_is_the_callers_bound() {
451        assert_eq!(
452            kind("gitlab.com/group/sub/project"),
453            ResourceErrorKind::TooManySegments { max: 2 }
454        );
455        assert_eq!(
456            normalize_resource("gitlab.com/Group/Sub/Project").as_deref(),
457            Ok("gitlab.com/group/sub/project")
458        );
459    }
460
461    #[test]
462    fn containment_is_by_whole_segment_and_never_crosses_forges() {
463        assert!(resource_contains("github.com/acme", "github.com/acme"));
464        assert!(resource_contains(
465            "github.com/acme",
466            "github.com/acme/widgets"
467        ));
468        assert!(!resource_contains(
469            "github.com/acme",
470            "github.com/acme-labs/x"
471        ));
472        assert!(!resource_contains(
473            "github.com/acme",
474            "codeberg.org/acme/widgets"
475        ));
476        assert!(!resource_contains(
477            "github.com/acme/widgets",
478            "github.com/acme"
479        ));
480    }
481
482    #[test]
483    fn long_hostile_inputs_are_truncated_in_errors() {
484        let err = normalize_resource(&"é".repeat(400)).unwrap_err();
485        assert!(err.input().len() < 100);
486    }
487}