1use clap::{Args, Subcommand, ValueEnum};
10use serde::Serialize;
11
12use zad::error::{Result, ZadError};
13use zad::permissions::mutation::{ListKind, Mutation};
14use zad::permissions::service::{PermissionsService, global_path, local_path_current};
15use zad::permissions::signing;
16use zad::permissions::staging;
17
18#[derive(Debug, Subcommand)]
25pub enum StagingAction {
26 Status(ScopeArgs),
28 Diff(ScopeArgs),
30 Discard(ScopeArgs),
32 Commit(ScopeArgs),
35 Sign(ScopeArgs),
40
41 #[command(alias = "add-pattern")]
43 Add(PatternMutationArgs),
44 #[command(alias = "remove-pattern")]
46 Remove(PatternMutationArgs),
47
48 Content(ContentMutationArgs),
51 Time(TimeMutationArgs),
53}
54
55#[derive(Debug, Args)]
56pub struct ScopeArgs {
57 #[arg(long)]
59 pub local: bool,
60 #[arg(long)]
61 pub json: bool,
62}
63
64#[derive(Debug, Args)]
65pub struct PatternMutationArgs {
66 #[arg(long)]
69 pub function: Option<String>,
70
71 #[arg(long)]
75 pub target: String,
76
77 #[arg(long, value_enum)]
79 pub list: CliListKind,
80
81 pub value: String,
83
84 #[arg(long)]
85 pub local: bool,
86 #[arg(long)]
87 pub json: bool,
88}
89
90#[derive(Debug, Copy, Clone, ValueEnum)]
91pub enum CliListKind {
92 Allow,
93 Deny,
94}
95
96impl From<CliListKind> for ListKind {
97 fn from(v: CliListKind) -> ListKind {
98 match v {
99 CliListKind::Allow => ListKind::Allow,
100 CliListKind::Deny => ListKind::Deny,
101 }
102 }
103}
104
105#[derive(Debug, Args)]
106pub struct ContentMutationArgs {
107 #[arg(long)]
110 pub function: Option<String>,
111
112 #[command(subcommand)]
113 pub action: ContentAction,
114
115 #[arg(long)]
116 pub local: bool,
117 #[arg(long)]
118 pub json: bool,
119}
120
121#[derive(Debug, Subcommand)]
122pub enum ContentAction {
123 AddDenyWord { word: String },
125 RemoveDenyWord { word: String },
127 AddDenyRegex { pattern: String },
129 RemoveDenyRegex { pattern: String },
131 SetMaxLength {
133 #[arg(long, conflicts_with = "clear")]
134 value: Option<u32>,
135 #[arg(long)]
136 clear: bool,
137 },
138}
139
140#[derive(Debug, Args)]
141pub struct TimeMutationArgs {
142 #[arg(long)]
143 pub function: Option<String>,
144
145 #[command(subcommand)]
146 pub action: TimeAction,
147
148 #[arg(long)]
149 pub local: bool,
150 #[arg(long)]
151 pub json: bool,
152}
153
154#[derive(Debug, Subcommand)]
155pub enum TimeAction {
156 SetDays {
158 #[arg(long, value_delimiter = ',')]
159 days: Vec<String>,
160 },
161 SetWindows {
163 #[arg(long, value_delimiter = ',')]
164 windows: Vec<String>,
165 },
166}
167
168pub fn run<S: PermissionsService>(action: StagingAction) -> Result<()> {
175 match action {
176 StagingAction::Status(a) => run_status::<S>(a),
177 StagingAction::Diff(a) => run_diff::<S>(a),
178 StagingAction::Discard(a) => run_discard::<S>(a),
179 StagingAction::Commit(a) => run_commit::<S>(a),
180 StagingAction::Sign(a) => run_sign::<S>(a),
181 StagingAction::Add(a) => run_pattern_mutation::<S>(a, true),
182 StagingAction::Remove(a) => run_pattern_mutation::<S>(a, false),
183 StagingAction::Content(a) => run_content_mutation::<S>(a),
184 StagingAction::Time(a) => run_time_mutation::<S>(a),
185 }
186}
187
188fn scope_path<S: PermissionsService>(local: bool) -> Result<std::path::PathBuf> {
189 if local {
190 local_path_current::<S>()
191 } else {
192 global_path::<S>()
193 }
194}
195
196#[derive(Debug, Serialize)]
197struct StatusOut {
198 command: String,
199 scope: &'static str,
200 live_path: String,
201 live_exists: bool,
202 pending_path: String,
203 pending_exists: bool,
204}
205
206fn run_status<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
207 let scope = if args.local { "local" } else { "global" };
208 let live = scope_path::<S>(args.local)?;
209 let pending = staging::pending_path_for(&live);
210 let st = staging::status(&live);
211 if args.json {
212 let out = StatusOut {
213 command: format!("{}.permissions.status", S::NAME),
214 scope,
215 live_path: live.display().to_string(),
216 live_exists: st.live_exists,
217 pending_path: pending.display().to_string(),
218 pending_exists: st.pending_exists,
219 };
220 println!("{}", serde_json::to_string_pretty(&out).unwrap());
221 } else {
222 println!("# {} permissions ({scope})", S::NAME);
223 println!(
224 " live : {} ({})",
225 live.display(),
226 if st.live_exists { "present" } else { "absent" }
227 );
228 println!(
229 " pending : {} ({})",
230 pending.display(),
231 if st.pending_exists {
232 "present"
233 } else {
234 "absent"
235 }
236 );
237 }
238 Ok(())
239}
240
241fn run_diff<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
242 let live = scope_path::<S>(args.local)?;
243 match staging::diff(&live)? {
244 Some(body) if body.is_empty() => {
245 println!("# no effective change (pending matches live)");
246 }
247 Some(body) => {
248 print!("{body}");
249 }
250 None => {
251 if args.json {
252 println!(
253 "{{\"command\": \"{}.permissions.diff\", \"pending\": false}}",
254 S::NAME
255 );
256 } else {
257 println!("# no pending changes at {}", live.display());
258 }
259 }
260 }
261 Ok(())
262}
263
264fn run_discard<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
265 let live = scope_path::<S>(args.local)?;
266 let removed = staging::discard(&live)?;
267 if args.json {
268 println!(
269 "{{\"command\": \"{}.permissions.discard\", \"removed\": {removed}}}",
270 S::NAME
271 );
272 } else if removed {
273 println!(
274 "Discarded pending changes at {}",
275 staging::pending_path_for(&live).display()
276 );
277 } else {
278 println!("No pending changes to discard.");
279 }
280 Ok(())
281}
282
283fn run_commit<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
284 let live = scope_path::<S>(args.local)?;
285 let key = signing::require_keychain_key()?;
286 staging::commit::<S>(&live, &key)?;
287 if args.json {
288 println!(
289 "{{\"command\": \"{}.permissions.commit\", \"signed_with\": \"{}\"}}",
290 S::NAME,
291 key.fingerprint()
292 );
293 } else {
294 println!(
295 "Committed and signed: {} (key {}).",
296 live.display(),
297 key.fingerprint()
298 );
299 }
300 Ok(())
301}
302
303fn run_sign<S: PermissionsService>(args: ScopeArgs) -> Result<()> {
304 let live = scope_path::<S>(args.local)?;
305 let key = signing::require_keychain_key()?;
306 staging::sign_in_place::<S>(&live, &key)?;
307 if args.json {
308 println!(
309 "{{\"command\": \"{}.permissions.sign\", \"signed_with\": \"{}\"}}",
310 S::NAME,
311 key.fingerprint()
312 );
313 } else {
314 println!(
315 "Signed and trusted: {} (key {}).",
316 live.display(),
317 key.fingerprint()
318 );
319 }
320 Ok(())
321}
322
323fn run_pattern_mutation<S: PermissionsService>(args: PatternMutationArgs, add: bool) -> Result<()> {
324 validate_function::<S>(args.function.as_deref())?;
325 validate_target::<S>(&args.target)?;
326 let mutation = if add {
327 Mutation::AddPattern {
328 function: args.function,
329 target: args.target,
330 list: args.list.into(),
331 value: args.value,
332 }
333 } else {
334 Mutation::RemovePattern {
335 function: args.function,
336 target: args.target,
337 list: args.list.into(),
338 value: args.value,
339 }
340 };
341 queue_and_report::<S>(&mutation, args.local, args.json)
342}
343
344fn run_content_mutation<S: PermissionsService>(args: ContentMutationArgs) -> Result<()> {
345 validate_function::<S>(args.function.as_deref())?;
346 let mutation = match args.action {
347 ContentAction::AddDenyWord { word } => Mutation::AddDenyWord {
348 function: args.function,
349 word,
350 },
351 ContentAction::RemoveDenyWord { word } => Mutation::RemoveDenyWord {
352 function: args.function,
353 word,
354 },
355 ContentAction::AddDenyRegex { pattern } => Mutation::AddDenyRegex {
356 function: args.function,
357 pattern,
358 },
359 ContentAction::RemoveDenyRegex { pattern } => Mutation::RemoveDenyRegex {
360 function: args.function,
361 pattern,
362 },
363 ContentAction::SetMaxLength { value, clear } => {
364 if clear && value.is_some() {
365 return Err(ZadError::Invalid(
366 "pass --value or --clear, not both".into(),
367 ));
368 }
369 Mutation::SetMaxLength {
370 function: args.function,
371 value: if clear { None } else { value },
372 }
373 }
374 };
375 queue_and_report::<S>(&mutation, args.local, args.json)
376}
377
378fn run_time_mutation<S: PermissionsService>(args: TimeMutationArgs) -> Result<()> {
379 validate_function::<S>(args.function.as_deref())?;
380 let mutation = match args.action {
381 TimeAction::SetDays { days } => Mutation::SetTimeDays {
382 function: args.function,
383 days,
384 },
385 TimeAction::SetWindows { windows } => Mutation::SetTimeWindows {
386 function: args.function,
387 windows,
388 },
389 };
390 queue_and_report::<S>(&mutation, args.local, args.json)
391}
392
393fn queue_and_report<S: PermissionsService>(
394 mutation: &Mutation,
395 local: bool,
396 json: bool,
397) -> Result<()> {
398 let live = scope_path::<S>(local)?;
399 staging::mutate_pending::<S>(&live, mutation)?;
400 let pending = staging::pending_path_for(&live);
401 if json {
402 println!(
403 "{{\"command\": \"{}.permissions.queue\", \"mutation\": {}, \"pending\": \"{}\"}}",
404 S::NAME,
405 serde_json::to_string(mutation).unwrap(),
406 pending.display()
407 );
408 } else {
409 println!("Queued: {}", mutation.summary());
410 println!(" pending: {}", pending.display());
411 println!(
412 " review with `zad {} permissions diff{}`, commit with \
413 `zad {} permissions commit{}`.",
414 S::NAME,
415 if local { " --local" } else { "" },
416 S::NAME,
417 if local { " --local" } else { "" }
418 );
419 }
420 Ok(())
421}
422
423fn validate_function<S: PermissionsService>(function: Option<&str>) -> Result<()> {
428 let Some(name) = function else {
429 return Ok(());
430 };
431 if S::all_functions().contains(&name) {
432 return Ok(());
433 }
434 Err(ZadError::Invalid(format!(
435 "{}: unknown function `{name}`; expected one of {}",
436 S::NAME,
437 S::all_functions().join(", ")
438 )))
439}
440
441fn validate_target<S: PermissionsService>(target: &str) -> Result<()> {
442 if S::target_kinds().contains(&target) {
443 return Ok(());
444 }
445 Err(ZadError::Invalid(format!(
446 "{}: unknown target `{target}`; expected one of {}",
447 S::NAME,
448 S::target_kinds().join(", ")
449 )))
450}