1use serde::Deserialize;
11
12use crate::policy::{Policy, PolicyError, RuleGroup};
13
14pub const REPO_RULE_PREFIX: &str = "repo:";
18
19#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct RepoPolicy {
24 pub version: u32,
26 #[serde(default)]
28 pub deny: Vec<RuleGroup>,
29 #[serde(default)]
31 pub allow: Vec<RuleGroup>,
32 #[serde(default, alias = "ask")]
35 pub review: Vec<RuleGroup>,
36}
37
38impl RepoPolicy {
39 pub fn parse(yaml: &str) -> Result<Self, PolicyError> {
41 let repo: Self = crate::yaml::from_str(yaml).map_err(PolicyError::Parse)?;
42 Policy::parse(yaml)?;
45 Ok(repo)
46 }
47
48 pub fn merge(&self, user: &Policy, trusted: bool) -> Result<Policy, PolicyError> {
52 let mut merged = user.clone();
53 merged.deny.extend(prefixed(&self.deny));
54 merged.repo_review.extend(prefixed(&self.review));
55 if trusted {
56 merged.allow.extend(prefixed(&self.allow));
57 }
58 merged.lint()?;
59 Ok(merged)
60 }
61
62 pub fn merge_deny(&self, user: &Policy) -> Result<Policy, PolicyError> {
67 let mut merged = user.clone();
68 merged.deny.extend(prefixed(&self.deny));
69 merged.lint()?;
70 Ok(merged)
71 }
72}
73
74fn prefixed(groups: &[RuleGroup]) -> impl Iterator<Item = RuleGroup> + '_ {
75 groups.iter().map(|g| RuleGroup {
76 id: format!("{REPO_RULE_PREFIX}{}", g.id),
77 ..g.clone()
78 })
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use crate::{Action, CompiledPolicy, EvalContext, Verdict};
85
86 const USER: &str = "version: 1\ndefaults: { '*': review, net: deny }\n\
87 deny:\n - id: secrets\n fs.read: ['~/.ssh/**']\n\
88 allow:\n - id: dev\n shell: ['cargo *', 'git status']\n";
89
90 fn decide(merged: &Policy, command: &str) -> (Verdict, Vec<String>) {
91 let ctx = EvalContext {
92 home: "/h".into(),
93 project: Some("/p".into()),
94 real_home: None,
95 real_project: None,
96 moved_dirs: Vec::new(),
97 cwd: "/p".into(),
98 case_insensitive_paths: false,
99 };
100 let d = CompiledPolicy::compile(merged, &ctx)
101 .unwrap()
102 .decide(&Action::Shell {
103 command: command.into(),
104 });
105 (d.verdict, d.rules)
106 }
107
108 fn merged(repo: &str, trusted: bool) -> Policy {
109 let user = Policy::parse(USER).unwrap();
110 RepoPolicy::parse(repo)
111 .unwrap()
112 .merge(&user, trusted)
113 .unwrap()
114 }
115
116 #[test]
117 fn deny_and_review_tighten_what_the_user_allows() {
118 let repo = "version: 1\ndeny:\n - id: no-status\n shell: ['git status']\n\
119 review:\n - id: publish\n shell: ['cargo publish']\n";
120 let p = merged(repo, false);
121 assert_eq!(
122 decide(&p, "git status"),
123 (Verdict::Deny, vec!["repo:no-status".into()])
124 );
125 assert_eq!(
126 decide(&p, "cargo publish"),
127 (Verdict::Review, vec!["repo:publish".into()])
128 );
129 assert_eq!(decide(&p, "cargo test").0, Verdict::Allow);
130 }
131
132 #[test]
133 fn allow_rules_widen_only_when_trusted_and_never_past_a_deny() {
134 let repo = "version: 1\nallow:\n - id: wide\n shell: ['make *', 'cat *']\n \
135 fs.read: ['~/.ssh/**']\n";
136 assert_eq!(
137 decide(&merged(repo, false), "make deploy").0,
138 Verdict::Review
139 );
140 let trusted = merged(repo, true);
141 assert_eq!(
142 decide(&trusted, "make deploy"),
143 (Verdict::Allow, vec!["repo:wide".into()])
144 );
145 assert_eq!(
146 decide(&trusted, "cat ~/.ssh/id_rsa"),
147 (Verdict::Deny, vec!["secrets".into()])
148 );
149 }
150
151 #[test]
152 fn merging_denies_only_adds_no_review_and_no_allow() {
153 let repo = "version: 1\ndeny:\n - id: no-status\n shell: ['git status']\n\
154 review:\n - id: publish\n shell: ['cargo publish']\n\
155 allow:\n - id: wide\n shell: ['make *']\n";
156 let user = Policy::parse(USER).unwrap();
157 let p = RepoPolicy::parse(repo).unwrap().merge_deny(&user).unwrap();
158 assert_eq!(
159 decide(&p, "git status"),
160 (Verdict::Deny, vec!["repo:no-status".into()])
161 );
162 assert_eq!(p.repo_review, Vec::<RuleGroup>::new());
163 assert_eq!(p.allow, user.allow);
164 assert_eq!(decide(&p, "make deploy").0, Verdict::Review);
165 }
166
167 #[test]
168 fn a_repo_review_still_loses_to_a_user_deny() {
169 let repo = "version: 1\nreview:\n - id: keys\n fs.read: ['~/.ssh/**']\n";
170 assert_eq!(
171 decide(&merged(repo, false), "cat ~/.ssh/id_rsa"),
172 (Verdict::Deny, vec!["secrets".into()])
173 );
174 }
175
176 #[test]
177 fn rule_ids_are_prefixed_so_they_cannot_pose_as_the_users() {
178 let repo = "version: 1\ndeny:\n - id: secrets\n shell: ['ls']\n";
179 let p = merged(repo, false);
180 let ids: Vec<&str> = p.deny.iter().map(|g| g.id.as_str()).collect();
181 assert_eq!(ids, ["secrets", "repo:secrets"]);
182 }
183
184 #[test]
185 fn rejects_everything_but_rules_and_bad_rules() {
186 for bad in [
187 "version: 1\ndefaults: allow\n",
188 "version: 1\ndefaults: { net: allow }\n",
189 "version: 1\nexecutables:\n git: ['/tmp/git']\n",
190 "version: 1\nsandbox:\n read_roots: ['/etc']\n",
191 "version: 1\nrepo_review: []\n",
192 "version: 2\n",
193 "version: 1\ndeny:\n - id: a\n",
194 "version: 1\ndeny:\n - id: a\n shell: ['x']\nreview:\n - id: a\n shell: ['y']\n",
195 "deny: []\n",
196 ] {
197 assert!(RepoPolicy::parse(bad).is_err(), "{bad} must be rejected");
198 }
199 }
200}