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