1use std::time::{Duration, Instant};
4
5use serde::Serialize;
6
7use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
8use crate::drive::client::DriveClient;
9use crate::drive::files_api::FilesApi;
10use crate::drive::folder_ancestry::{self, DecisionSource};
11use crate::drive::types::GOOGLE_FOLDER_MIME_TYPE;
12use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule, Verdict};
13use crate::request_log::{self, DriveMutationOutcome};
14
15#[derive(Debug, Clone)]
17pub struct TrashOptions {
18 pub file_id: String,
20 pub restore: bool,
22 pub dry_run: bool,
24}
25
26#[derive(Debug, Clone, Serialize)]
28#[serde(tag = "status", rename_all = "kebab-case")]
29pub enum TrashResult {
30 WouldTrash,
32 Trashed,
34 WouldUntrash,
36 Untrashed,
38 AlreadyTrashed,
40 NotTrashed,
42 RefusedFolder,
44 RefusedNoVisibleParents,
46 Blocked {
48 decided_by: Option<DecidingRule>,
50 },
51 Failed {
53 detail: String,
55 },
56}
57
58impl TrashResult {
59 fn log_status(&self) -> &'static str {
61 match self {
62 Self::WouldTrash => "would-trash",
63 Self::Trashed => "trashed",
64 Self::WouldUntrash => "would-untrash",
65 Self::Untrashed => "untrashed",
66 Self::AlreadyTrashed => "already-trashed",
67 Self::NotTrashed => "not-trashed",
68 Self::RefusedFolder => "refused-folder",
69 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
70 Self::Blocked { .. } => "blocked",
71 Self::Failed { .. } => "failed",
72 }
73 }
74}
75
76#[derive(Debug, Clone, Serialize)]
78pub struct TrashOutcome {
79 pub file_id: String,
81 pub file_name: Option<String>,
83 pub resolved_folder_id: Option<String>,
86 pub result: TrashResult,
88}
89
90impl JsonlSerialize for TrashOutcome {
91 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<(), anyhow::Error> {
92 write_scalar_jsonl(self, out)
93 }
94}
95
96pub async fn trash(
101 client: &DriveClient,
102 opts: &TrashOptions,
103 rules: &[FolderPermissionRule],
104) -> TrashOutcome {
105 let started = Instant::now();
106 let outcome = trash_inner(client, opts, rules).await;
107 if !opts.dry_run {
108 record_attempt(&outcome, opts.restore, started.elapsed());
109 }
110 outcome
111}
112
113async fn trash_inner(
114 client: &DriveClient,
115 opts: &TrashOptions,
116 rules: &[FolderPermissionRule],
117) -> TrashOutcome {
118 let mut outcome = TrashOutcome {
119 file_id: opts.file_id.clone(),
120 file_name: None,
121 resolved_folder_id: None,
122 result: TrashResult::Failed {
123 detail: String::new(),
124 },
125 };
126 let files_api = FilesApi::new(client);
127 let target = match files_api.get_metadata(&opts.file_id).await {
128 Ok(target) => target,
129 Err(err) => {
130 outcome.result = TrashResult::Failed {
131 detail: err.to_string(),
132 };
133 return outcome;
134 }
135 };
136 outcome.file_name = Some(target.name.clone());
137 if target.mime_type == GOOGLE_FOLDER_MIME_TYPE {
138 outcome.result = TrashResult::RefusedFolder;
139 return outcome;
140 }
141 let evaluated = match folder_ancestry::resolve_decision_for_file_target(
142 &files_api,
143 &target,
144 DriveOperation::Trash,
145 rules,
146 )
147 .await
148 {
149 Ok(evaluated) => evaluated,
150 Err(err) => {
151 outcome.result = TrashResult::Failed {
152 detail: err.to_string(),
153 };
154 return outcome;
155 }
156 };
157 if evaluated.source == DecisionSource::NoVisibleParents {
158 outcome.result = TrashResult::RefusedNoVisibleParents;
159 return outcome;
160 }
161 outcome.resolved_folder_id = evaluated.resolved_folder_id;
162 if evaluated.decision.verdict == Verdict::Deny {
163 outcome.result = TrashResult::Blocked {
164 decided_by: evaluated.decision.decided_by,
165 };
166 return outcome;
167 }
168 let Some(trashed) = target.trashed else {
169 outcome.result = TrashResult::Failed {
170 detail: "Drive metadata omitted the requested trashed state".to_string(),
171 };
172 return outcome;
173 };
174 if trashed != opts.restore {
175 outcome.result = if opts.restore {
176 TrashResult::NotTrashed
177 } else {
178 TrashResult::AlreadyTrashed
179 };
180 return outcome;
181 }
182 if opts.dry_run {
183 outcome.result = if opts.restore {
184 TrashResult::WouldUntrash
185 } else {
186 TrashResult::WouldTrash
187 };
188 return outcome;
189 }
190 let updated = if opts.restore {
194 files_api.untrash(&opts.file_id).await
195 } else {
196 files_api.trash(&opts.file_id).await
197 };
198 outcome.result = match updated {
199 Ok(updated) if updated.trashed != Some(!opts.restore) => TrashResult::Failed {
200 detail: if opts.restore {
201 "Drive did not confirm restoration; the file may still be in a trashed parent folder"
202 } else {
203 "Drive did not confirm the requested trashed state"
204 }.to_string(),
205 },
206 Ok(_) if opts.restore => TrashResult::Untrashed,
207 Ok(_) => TrashResult::Trashed,
208 Err(err) => TrashResult::Failed {
209 detail: err.to_string(),
210 },
211 };
212 outcome
213}
214
215fn record_attempt(outcome: &TrashOutcome, restore: bool, duration: Duration) {
216 let error = match &outcome.result {
217 TrashResult::Failed { detail } => Some(detail.clone()),
218 _ => None,
219 };
220 let decided_by = match &outcome.result {
221 TrashResult::Blocked { decided_by } => decided_by.as_ref(),
222 _ => None,
223 };
224 let decided_by = write_gate::decided_by_log_fields(decided_by);
225 request_log::record_drive_mutation(DriveMutationOutcome {
226 operation: if restore { "untrash" } else { "trash" },
227 file_id: outcome.file_id.clone(),
228 file_name: outcome.file_name.clone().unwrap_or_default(),
229 status: outcome.result.log_status().to_string(),
230 resolved_folder_id: outcome.resolved_folder_id.clone(),
231 decided_by_folder_id: decided_by.folder_id,
232 decided_by_file_id: decided_by.file_id,
233 decided_by_depth: decided_by.depth,
234 error,
235 duration,
236 ..Default::default()
237 });
238}
239
240#[cfg(test)]
241#[allow(clippy::unwrap_used, clippy::expect_used)]
242mod tests {
243 use super::*;
244 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
245 use crate::utils::secret::Secret;
246 use wiremock::matchers::{body_json, method, path, query_param};
247 use wiremock::{Mock, MockServer, ResponseTemplate};
248
249 fn test_credentials() -> DriveCredentials {
250 DriveCredentials {
251 client_id: "client-1".to_string(),
252 client_secret: Secret::new("secret-1"),
253 refresh_token: Secret::new("refresh-1"),
254 scope: DriveGrantedScopes::READONLY,
255 }
256 }
257
258 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
259 wiremock::Mock::given(wiremock::matchers::method("POST"))
260 .and(wiremock::matchers::path("/token"))
261 .respond_with(
262 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
263 "access_token": "test-token",
264 "expires_in": 3600,
265 })),
266 )
267 .mount(server)
268 .await;
269
270 let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
271 crate::drive::client::test_support::replace_session(
272 &mut client,
273 &test_credentials(),
274 &format!("{}/token", server.uri()),
275 );
276 client
277 }
278
279 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
280 let parents_json: Vec<&str> = parents.to_vec();
281 wiremock::Mock::given(wiremock::matchers::method("GET"))
282 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
283 .respond_with(
284 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
285 "id": id, "name": id, "mimeType": mime_type, "parents": parents_json,
286 "version": "1", "trashed": false,
287 })),
288 )
289 }
290
291 fn mount_folder(id: &str) -> wiremock::Mock {
292 wiremock::Mock::given(wiremock::matchers::method("GET"))
293 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
294 .respond_with(
295 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
296 "id": id, "name": id, "mimeType": GOOGLE_FOLDER_MIME_TYPE,
297 })),
298 )
299 }
300
301 fn opts(restore: bool, dry_run: bool) -> TrashOptions {
302 TrashOptions {
303 file_id: "file-1".into(),
304 restore,
305 dry_run,
306 }
307 }
308
309 fn allow() -> FolderPermissionRule {
310 FolderPermissionRule::folder("parent-1").allowing([DriveOperation::Trash])
312 }
313
314 async fn no_patch(server: &MockServer) {
315 Mock::given(method("PATCH"))
316 .respond_with(ResponseTemplate::new(500))
317 .expect(0)
318 .mount(server)
319 .await;
320 }
321
322 async fn trashed_file(server: &MockServer) {
323 Mock::given(method("GET"))
324 .and(path("/drive/v3/files/file-1"))
325 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
326 "id": "file-1", "name": "file-1", "mimeType": "text/plain",
327 "parents": ["parent-1"], "trashed": true
328 })))
329 .mount(server)
330 .await;
331 }
332
333 #[tokio::test]
334 async fn gate_refusals_and_folder_boundaries_never_patch() {
335 for (mime, parents, rules, expected) in [
336 ("text/plain", vec!["parent-1"], vec![], "blocked"),
337 (
338 "text/plain",
339 vec!["parent-1"],
340 vec![FolderPermissionRule::folder("parent-1").denying([DriveOperation::Trash])],
341 "blocked",
342 ),
343 (
344 "text/plain",
345 vec!["parent-1"],
346 vec![
347 allow(),
348 FolderPermissionRule::file("file-1").denying([DriveOperation::Trash]),
349 ],
350 "blocked",
351 ),
352 (
353 "text/plain",
354 vec![],
355 vec![allow()],
356 "refused-no-visible-parents",
357 ),
358 (
359 GOOGLE_FOLDER_MIME_TYPE,
360 vec!["parent-1"],
361 vec![FolderPermissionRule::file("file-1").allowing([DriveOperation::Trash])],
362 "refused-folder",
363 ),
364 ] {
365 for restore in [false, true] {
366 let server = MockServer::start().await;
367 let client = client_with_bootstrapped_token(&server).await;
368 mount_file("file-1", mime, &parents).mount(&server).await;
369 mount_folder("parent-1").mount(&server).await;
370 no_patch(&server).await;
371 let outcome = trash(&client, &opts(restore, true), &rules).await;
372 assert_eq!(outcome.result.log_status(), expected);
373 }
374 }
375 }
376
377 #[tokio::test]
378 async fn dry_runs_and_noops_never_patch() {
379 for (restore, trashed, dry_run, expected) in [
380 (false, false, true, "would-trash"),
381 (true, true, true, "would-untrash"),
382 (false, true, true, "already-trashed"),
383 (true, false, true, "not-trashed"),
384 ] {
385 let server = MockServer::start().await;
386 let client = client_with_bootstrapped_token(&server).await;
387 if trashed {
388 trashed_file(&server).await;
389 } else {
390 mount_file("file-1", "text/plain", &["parent-1"])
391 .mount(&server)
392 .await;
393 }
394 mount_folder("parent-1").mount(&server).await;
395 no_patch(&server).await;
396 let outcome = trash(&client, &opts(restore, dry_run), &[allow()]).await;
397 assert_eq!(outcome.result.log_status(), expected);
398 assert_eq!(outcome.resolved_folder_id.as_deref(), Some("parent-1"));
399 }
400 }
401
402 #[tokio::test]
403 async fn noops_are_still_gated() {
404 let server = MockServer::start().await;
405 let client = client_with_bootstrapped_token(&server).await;
406 trashed_file(&server).await;
407 mount_folder("parent-1").mount(&server).await;
408 no_patch(&server).await;
409 let outcome = trash(&client, &opts(false, true), &[]).await;
410 assert!(matches!(outcome.result, TrashResult::Blocked { .. }));
411 }
412
413 #[tokio::test]
414 async fn file_rule_grants_parentless_native_document() {
415 let server = MockServer::start().await;
416 let client = client_with_bootstrapped_token(&server).await;
417 mount_file("file-1", "application/vnd.google-apps.spreadsheet", &[])
418 .mount(&server)
419 .await;
420 no_patch(&server).await;
421 let rule = FolderPermissionRule::file("file-1").allowing([DriveOperation::Trash]);
422 let outcome = trash(&client, &opts(false, true), &[rule]).await;
423 assert!(matches!(outcome.result, TrashResult::WouldTrash));
424 assert_eq!(outcome.resolved_folder_id, None);
425 }
426
427 #[tokio::test]
428 async fn ancestor_allow_and_multiple_parent_deny_use_the_shared_gate() {
429 let server = MockServer::start().await;
430 let client = client_with_bootstrapped_token(&server).await;
431 mount_file("file-1", "text/plain", &["parent-1", "parent-2"])
432 .mount(&server)
433 .await;
434 mount_folder("parent-1").mount(&server).await;
435 mount_file("parent-2", GOOGLE_FOLDER_MIME_TYPE, &["parent-1"])
436 .mount(&server)
437 .await;
438 no_patch(&server).await;
439 let rules = [allow().recursive(true)];
440 let outcome = trash(&client, &opts(false, true), &rules).await;
441 assert!(matches!(outcome.result, TrashResult::WouldTrash));
442 assert_eq!(outcome.resolved_folder_id, None);
443 let rules = [
444 allow().recursive(true),
445 FolderPermissionRule::folder("parent-2").denying([DriveOperation::Trash]),
446 ];
447 let outcome = trash(&client, &opts(false, true), &rules).await;
448 assert!(matches!(outcome.result, TrashResult::Blocked { .. }));
449 }
450
451 #[tokio::test]
452 async fn metadata_and_ancestry_errors_never_patch() {
453 for failed_id in ["file-1", "parent-1"] {
454 let server = MockServer::start().await;
455 let client = client_with_bootstrapped_token(&server).await;
456 if failed_id == "parent-1" {
457 mount_file("file-1", "text/plain", &["parent-1"])
458 .mount(&server)
459 .await;
460 }
461 Mock::given(method("GET"))
462 .and(path(format!("/drive/v3/files/{failed_id}")))
463 .respond_with(ResponseTemplate::new(500).set_body_string("lookup failed"))
464 .mount(&server)
465 .await;
466 no_patch(&server).await;
467 let outcome = trash(&client, &opts(false, true), &[allow()]).await;
468 assert!(matches!(outcome.result, TrashResult::Failed { .. }));
469 }
470 }
471
472 #[tokio::test]
473 async fn missing_requested_state_never_reports_a_noop_or_patches() {
474 let server = MockServer::start().await;
475 let client = client_with_bootstrapped_token(&server).await;
476 Mock::given(method("GET"))
477 .and(path("/drive/v3/files/file-1"))
478 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
479 "id": "file-1", "name": "file-1", "mimeType": "text/plain"
480 })))
481 .mount(&server)
482 .await;
483 no_patch(&server).await;
484 let rules = [FolderPermissionRule::file("file-1").allowing([DriveOperation::Trash])];
485 for restore in [false, true] {
486 let outcome = trash(&client, &opts(restore, true), &rules).await;
487 assert!(
488 matches!(outcome.result, TrashResult::Failed { ref detail } if detail.contains("omitted"))
489 );
490 }
491 }
492
493 #[tokio::test]
494 async fn successful_patch_must_confirm_the_requested_state() {
495 let _env = crate::drive::test_support::EnvGuard::take();
496 let dir = tempfile::tempdir().unwrap();
497 let log_path = dir.path().join("mutations.jsonl");
498 let _log =
499 crate::utils::env::ScopedEnvVar::set("OMNI_DEV_LOG_FILE", log_path.to_str().unwrap());
500 for (restore, returned) in [(true, Some(true)), (false, Some(false)), (true, None)] {
501 let server = MockServer::start().await;
502 let client = client_with_bootstrapped_token(&server).await;
503 if restore {
504 trashed_file(&server).await;
505 } else {
506 mount_file("file-1", "text/plain", &["parent-1"])
507 .mount(&server)
508 .await;
509 }
510 mount_folder("parent-1").mount(&server).await;
511 Mock::given(method("PATCH"))
512 .and(path("/drive/v3/files/file-1"))
513 .and(body_json(serde_json::json!({"trashed": !restore})))
514 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
515 "id": "file-1", "name": "file-1", "trashed": returned
516 })))
517 .expect(1)
518 .mount(&server)
519 .await;
520 let outcome = trash(&client, &opts(restore, false), &[allow()]).await;
521 assert!(
522 matches!(outcome.result, TrashResult::Failed { ref detail } if detail.contains("did not confirm")),
523 "{outcome:?}"
524 );
525 }
526 }
527
528 #[tokio::test]
529 async fn mutations_noops_failures_and_previews_record_the_expected_log() {
530 let _env = crate::drive::test_support::EnvGuard::take();
531 let dir = tempfile::tempdir().unwrap();
532 let log_path = dir.path().join("mutations.jsonl");
533 let _log =
534 crate::utils::env::ScopedEnvVar::set("OMNI_DEV_LOG_FILE", log_path.to_str().unwrap());
535 let _enabled = crate::utils::env::ScopedEnvVar::set("OMNI_DEV_LOG_DISABLE", "false");
536 for (restore, trashed, dry_run, code, expected, count) in [
537 (false, false, false, 200, "trashed", 1),
538 (true, true, false, 200, "untrashed", 1),
539 (false, false, false, 403, "failed", 1),
540 (true, true, false, 403, "failed", 1),
541 (false, true, false, 200, "already-trashed", 0),
542 (true, false, false, 200, "not-trashed", 0),
543 (false, false, true, 200, "would-trash", 0),
544 (false, false, false, 200, "blocked", 0),
545 ] {
546 std::fs::write(&log_path, "").unwrap();
547 request_log::record_drive_mutation(DriveMutationOutcome {
550 operation: "edit",
551 file_id: "file-1".into(),
552 status: "edited".into(),
553 ..Default::default()
554 });
555 let server = MockServer::start().await;
556 let client = client_with_bootstrapped_token(&server).await;
557 if trashed {
558 trashed_file(&server).await;
559 } else {
560 mount_file("file-1", "text/plain", &["parent-1"])
561 .mount(&server)
562 .await;
563 }
564 mount_folder("parent-1").mount(&server).await;
565 Mock::given(method("PATCH"))
566 .and(path("/drive/v3/files/file-1"))
567 .and(query_param("supportsAllDrives", "true"))
568 .and(body_json(serde_json::json!({"trashed": !restore})))
569 .respond_with(
570 ResponseTemplate::new(code).set_body_json(serde_json::json!({
571 "id": "file-1", "name": "file-1", "trashed": !restore,
572 "error": {"message": "permission denied"}
573 })),
574 )
575 .expect(count)
576 .mount(&server)
577 .await;
578 let rules = if expected == "blocked" {
579 vec![]
580 } else {
581 vec![allow()]
582 };
583 assert!(allow().require_lease);
584 let outcome = trash(&client, &opts(restore, dry_run), &rules).await;
585 assert_eq!(outcome.result.log_status(), expected, "{outcome:?}");
586 let records: Vec<serde_json::Value> = std::fs::read_to_string(&log_path)
587 .unwrap()
588 .lines()
589 .map(|line| serde_json::from_str(line).unwrap())
590 .filter(|rec: &serde_json::Value| {
591 rec["kind"] == "drivemutation"
592 && rec["context"]["file_id"] == "file-1"
593 && rec["command"]
594 == serde_json::json!([
595 "drive",
596 if restore { "untrash" } else { "trash" }
597 ])
598 })
599 .collect();
600 assert_eq!(records.len(), usize::from(!dry_run));
601 if !dry_run {
602 assert_eq!(records[0]["context"]["status"], expected);
603 assert_eq!(records[0]["context"]["file_id"], "file-1");
604 assert_eq!(
605 records[0]["command"][1],
606 if restore { "untrash" } else { "trash" }
607 );
608 if expected == "failed" {
609 assert!(records[0]["error"].as_str().unwrap().contains("403"));
610 }
611 }
612 }
613 }
614}