1use std::path::Path;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Trust {
11 FirstParty,
13 Official,
15 ThirdParty,
17}
18
19const OFFICIAL_OWNERS: &[&str] = &["actions", "github"];
20
21pub struct TrustContext {
23 repo_owner: Option<String>,
24 trusted_owners: Vec<String>,
25}
26
27impl TrustContext {
28 pub fn new(repo_owner: Option<String>, trusted_owners: Vec<String>) -> Self {
29 Self {
30 repo_owner,
31 trusted_owners,
32 }
33 }
34
35 pub fn classify(&self, owner_repo: &str) -> Trust {
37 let owner = owner_repo.split('/').next().unwrap_or("");
38 if let Some(mine) = &self.repo_owner
39 && owner.eq_ignore_ascii_case(mine)
40 {
41 return Trust::FirstParty;
42 }
43 if self
44 .trusted_owners
45 .iter()
46 .any(|t| owner.eq_ignore_ascii_case(t))
47 {
48 return Trust::FirstParty;
49 }
50 if OFFICIAL_OWNERS
51 .iter()
52 .any(|o| owner.eq_ignore_ascii_case(o))
53 {
54 return Trust::Official;
55 }
56 Trust::ThirdParty
57 }
58}
59
60pub fn detect_repo_owner(root: &Path) -> Option<String> {
62 let config = std::fs::read_to_string(root.join(".git").join("config")).ok()?;
63 owner_from_git_config(&config)
64}
65
66fn owner_from_git_config(config: &str) -> Option<String> {
67 let mut in_origin = false;
68 for line in config.lines() {
69 let t = line.trim();
70 if t.starts_with('[') {
71 in_origin = t == r#"[remote "origin"]"#;
72 continue;
73 }
74 if !in_origin {
75 continue;
76 }
77 if let Some(url) = t
78 .strip_prefix("url")
79 .map(str::trim_start)
80 .and_then(|r| r.strip_prefix('='))
81 {
82 return owner_from_url(url.trim());
83 }
84 }
85 None
86}
87
88fn owner_from_url(url: &str) -> Option<String> {
90 let rest = url.split("github.com").nth(1)?;
91 let rest = rest.strip_prefix(':').or_else(|| rest.strip_prefix('/'))?;
92 let owner = rest.split('/').next()?;
93 if owner.is_empty() {
94 None
95 } else {
96 Some(owner.to_string())
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::{Trust, TrustContext, owner_from_git_config};
103
104 fn ctx(repo_owner: Option<&str>, trusted: &[&str]) -> TrustContext {
105 TrustContext::new(
106 repo_owner.map(str::to_string),
107 trusted.iter().map(|s| s.to_string()).collect(),
108 )
109 }
110
111 #[test]
112 fn same_owner_is_first_party_case_insensitive() {
113 assert_eq!(
114 ctx(Some("myorg"), &[]).classify("MyOrg/tool"),
115 Trust::FirstParty
116 );
117 }
118
119 #[test]
120 fn configured_trusted_org_is_first_party() {
121 let c = ctx(Some("myorg"), &["partner-org"]);
122 assert_eq!(c.classify("Partner-Org/tool"), Trust::FirstParty);
123 assert_eq!(c.classify("stranger/tool"), Trust::ThirdParty);
124 }
125
126 #[test]
127 fn github_owned_actions_are_official() {
128 assert_eq!(
129 ctx(Some("myorg"), &[]).classify("actions/checkout"),
130 Trust::Official
131 );
132 assert_eq!(
133 ctx(None, &[]).classify("github/codeql-action"),
134 Trust::Official
135 );
136 }
137
138 #[test]
139 fn everyone_else_is_third_party_even_security_vendors() {
140 assert_eq!(
141 ctx(Some("myorg"), &[]).classify("aquasecurity/trivy-action"),
142 Trust::ThirdParty
143 );
144 assert_eq!(ctx(None, &[]).classify("myorg/tool"), Trust::ThirdParty);
146 }
147
148 #[test]
149 fn extracts_owner_from_https_and_ssh_urls() {
150 let https = "[remote \"origin\"]\n\turl = https://github.com/kihyun1998/just-shield.git\n";
151 assert_eq!(owner_from_git_config(https).as_deref(), Some("kihyun1998"));
152 let ssh = "[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@github.com:someorg/repo.git\n";
153 assert_eq!(owner_from_git_config(ssh).as_deref(), Some("someorg"));
154 }
155
156 #[test]
157 fn non_github_or_missing_origin_yields_none() {
158 let gitlab = "[remote \"origin\"]\n\turl = https://gitlab.com/o/r.git\n";
159 assert_eq!(owner_from_git_config(gitlab), None);
160 assert_eq!(owner_from_git_config("[core]\n\tbare = false\n"), None);
161 }
162}