Skip to main content

omni_dev/drive/
trash.rs

1//! Gated, single-file Trash and restore operations (ADR-0092).
2
3use 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/// Options for changing an individual file's trashed state.
16#[derive(Debug, Clone)]
17pub struct TrashOptions {
18    /// Target Drive file ID.
19    pub file_id: String,
20    /// Restore from Trash instead of moving to Trash.
21    pub restore: bool,
22    /// Evaluate permissions and report the action without mutating.
23    pub dry_run: bool,
24}
25
26/// The result of a Trash or restore attempt.
27#[derive(Debug, Clone, Serialize)]
28#[serde(tag = "status", rename_all = "kebab-case")]
29pub enum TrashResult {
30    /// The permitted preview would move the file to Trash.
31    WouldTrash,
32    /// The file was moved to Trash.
33    Trashed,
34    /// The permitted preview would restore the file.
35    WouldUntrash,
36    /// The file was restored.
37    Untrashed,
38    /// The permitted target is already in Trash; no PATCH was sent.
39    AlreadyTrashed,
40    /// The permitted target is already outside Trash; no PATCH was sent.
41    NotTrashed,
42    /// Folders affect descendants and are refused even with a file-ID grant.
43    RefusedFolder,
44    /// There are no visible parents and no file-ID rule granting access.
45    RefusedNoVisibleParents,
46    /// The permission gate denied access.
47    Blocked {
48        /// The deciding rule, or `None` for the default policy.
49        decided_by: Option<DecidingRule>,
50    },
51    /// Metadata lookup, permission evaluation or mutation failed.
52    Failed {
53        /// Human-readable error detail.
54        detail: String,
55    },
56}
57
58impl TrashResult {
59    /// Stable status used by the mutation audit log.
60    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/// Structured outcome, shared by terminal output, JSON and audit logging.
77#[derive(Debug, Clone, Serialize)]
78pub struct TrashOutcome {
79    /// Target Drive file ID.
80    pub file_id: String,
81    /// Target's name, once metadata has been fetched.
82    pub file_name: Option<String>,
83    /// The single current parent evaluated by the gate, if applicable.
84    /// Absent for file-ID rules, multiple parents and pre-gate refusals.
85    pub resolved_folder_id: Option<String>,
86    /// The attempt's result.
87    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
96/// Changes the target's trashed state after evaluating `trash` permissions.
97///
98/// Every real attempt writes a best-effort mutation record, including refusals
99/// and no-ops. Dry runs never mutate or write mutation records.
100pub 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    // ADR-0092 extends ADR-0080's lease exemptions. Ignore
191    // FileTargetDecision::requires_lease as its documentation requires for
192    // operations that can never take a lease: Trash provides recovery itself.
193    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        // Default require_lease=true deliberately proves Trash is exempt.
311        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            // Other engine tests can log concurrently without changing env vars.
548            // Exercise that contamination explicitly, even for a serial run.
549            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}