Skip to main content

omni_dev/drive/
content_edit.rs

1//! Drive file content edit — replaces an existing file's content (issue
2//! #1574, [ADR-0071](../../docs/adrs/adr-0071.md)).
3//!
4//! The most structurally distinct of the three mutating verbs: unlike
5//! `create`/`upload` (whose gate chain starts at the caller-given
6//! `--parent`), `edit`'s chain starts at the target's *current* parent
7//! folder(s) — `files.get` first, then
8//! [`folder_ancestry::resolve_decision_for_parents`] resolves and combines
9//! a decision per parent for a legacy multi-parent file (mirrors
10//! `visibility.rs`'s existing multi-parent-union contract; shared with
11//! `drive permissions check`'s identical file-target case). An orphan file
12//! with no parent degenerates to the bare default policy, correctly, via
13//! an empty chain.
14//!
15//! Still single-target, so this follows `create.rs`/`upload.rs`'s linear-
16//! function shape, not `file_move.rs`'s batch Plan/Execute.
17
18use std::time::{Duration, Instant};
19
20use serde::Serialize;
21
22use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
23use crate::drive::client::DriveClient;
24use crate::drive::files_api::FilesApi;
25use crate::drive::folder_ancestry;
26use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
27use crate::request_log::{self, DriveMutationOutcome};
28
29/// Per-call edit options.
30#[derive(Debug, Clone)]
31pub struct EditOptions {
32    /// The file id to edit.
33    pub file_id: String,
34    /// The new content, already read into memory (and already
35    /// size-checked) by the caller.
36    pub content: Vec<u8>,
37    /// The content's MIME type.
38    pub content_type: String,
39    /// When `true`, classify but never call `files.update`.
40    pub dry_run: bool,
41}
42
43/// What happened (or, under `--dry-run`, would happen).
44#[derive(Debug, Clone, Serialize)]
45#[serde(tag = "status", rename_all = "kebab-case")]
46pub enum EditResult {
47    /// `--dry-run`, and the gate would allow it.
48    WouldEdit,
49    /// The target is a Google-native document (Docs/Sheets/Slides/...)
50    /// with no fixed byte content a raw media PATCH can replace. Checked
51    /// client-side, before the gate — this isn't a policy decision, the
52    /// operation is simply nonsensical for this target.
53    RefusedNativeDocument,
54    /// The folder write-permission gate refused it.
55    Blocked {
56        /// The rule that decided the refusal, if any.
57        decided_by: Option<DecidingRule>,
58    },
59    /// `files.update` (media) succeeded.
60    Edited,
61    /// An API/validation error.
62    Failed {
63        /// A human-readable summary of what failed.
64        detail: String,
65    },
66}
67
68impl EditResult {
69    /// The request-log `status` string — mirrors
70    /// `CreateResult`/`UploadResult`/`MoveResult::log_status`'s precedent.
71    fn log_status(&self) -> &'static str {
72        match self {
73            Self::WouldEdit => "would-edit",
74            Self::RefusedNativeDocument => "refused-native-document",
75            Self::Blocked { .. } => "blocked",
76            Self::Edited => "edited",
77            Self::Failed { .. } => "failed",
78        }
79    }
80}
81
82/// The planned (and, after a real run, final) outcome of one `edit` call.
83#[derive(Debug, Clone, Serialize)]
84pub struct EditOutcome {
85    /// The target file id.
86    pub file_id: String,
87    /// The file's name at the time of the attempt, once known (absent if
88    /// the initial `files.get` itself failed).
89    pub file_name: Option<String>,
90    /// The folder the write-permission gate evaluated against — the
91    /// target's resolved current parent, when the target has exactly one;
92    /// `None` for an orphan target, a target refused before the gate ran
93    /// (`RefusedNativeDocument`), or a target with more than one current
94    /// parent (no single folder to report).
95    pub resolved_folder_id: Option<String>,
96    /// The result.
97    pub result: EditResult,
98}
99
100impl JsonlSerialize for EditOutcome {
101    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<(), anyhow::Error> {
102        write_scalar_jsonl(self, out)
103    }
104}
105
106/// Replaces `opts.file_id`'s content with `opts.content`, gated by `rules`.
107///
108/// Every real (non-dry-run) attempt is logged; a `--dry-run` preview never
109/// is, matching `create`/`upload`/`move`'s existing precedent.
110pub async fn edit(
111    client: &DriveClient,
112    opts: &EditOptions,
113    rules: &[FolderPermissionRule],
114) -> EditOutcome {
115    let started = Instant::now();
116    let outcome = edit_inner(client, opts, rules).await;
117    if !opts.dry_run {
118        record_attempt(&outcome, started.elapsed());
119    }
120    outcome
121}
122
123async fn edit_inner(
124    client: &DriveClient,
125    opts: &EditOptions,
126    rules: &[FolderPermissionRule],
127) -> EditOutcome {
128    let files_api = FilesApi::new(client);
129    let target = match files_api.get_metadata(&opts.file_id).await {
130        Ok(target) => target,
131        Err(err) => {
132            return EditOutcome {
133                file_id: opts.file_id.clone(),
134                file_name: None,
135                resolved_folder_id: None,
136                result: EditResult::Failed {
137                    detail: err.to_string(),
138                },
139            }
140        }
141    };
142
143    if target.is_google_native() {
144        return EditOutcome {
145            file_id: opts.file_id.clone(),
146            file_name: Some(target.name),
147            resolved_folder_id: None,
148            result: EditResult::RefusedNativeDocument,
149        };
150    }
151
152    let (decision, resolved_folder_id) = match folder_ancestry::resolve_decision_for_parents(
153        &files_api,
154        &target.parents,
155        DriveOperation::Edit,
156        rules,
157    )
158    .await
159    {
160        Ok(evaluated) => evaluated,
161        Err(err) => {
162            return EditOutcome {
163                file_id: opts.file_id.clone(),
164                file_name: Some(target.name),
165                resolved_folder_id: None,
166                result: EditResult::Failed {
167                    detail: err.to_string(),
168                },
169            }
170        }
171    };
172
173    if decision.verdict == write_gate::Verdict::Deny {
174        return EditOutcome {
175            file_id: opts.file_id.clone(),
176            file_name: Some(target.name),
177            resolved_folder_id,
178            result: EditResult::Blocked {
179                decided_by: decision.decided_by,
180            },
181        };
182    }
183
184    if opts.dry_run {
185        return EditOutcome {
186            file_id: opts.file_id.clone(),
187            file_name: Some(target.name),
188            resolved_folder_id,
189            result: EditResult::WouldEdit,
190        };
191    }
192
193    let result = match files_api
194        .edit_content(&opts.file_id, &opts.content, &opts.content_type)
195        .await
196    {
197        Ok(_) => EditResult::Edited,
198        Err(err) => EditResult::Failed {
199            detail: err.to_string(),
200        },
201    };
202    EditOutcome {
203        file_id: opts.file_id.clone(),
204        file_name: Some(target.name),
205        resolved_folder_id,
206        result,
207    }
208}
209
210/// Builds and writes the [`DriveMutationOutcome`] for one `edit` attempt.
211fn record_attempt(outcome: &EditOutcome, duration: Duration) {
212    let error = match &outcome.result {
213        EditResult::Failed { detail } => Some(detail.clone()),
214        _ => None,
215    };
216    let decided_by = match &outcome.result {
217        EditResult::Blocked { decided_by } => decided_by.as_ref(),
218        _ => None,
219    };
220    let (decided_by_folder_id, decided_by_depth) = write_gate::decided_by_log_fields(decided_by);
221    request_log::record_drive_mutation(DriveMutationOutcome {
222        operation: "edit",
223        file_id: outcome.file_id.clone(),
224        file_name: outcome.file_name.clone().unwrap_or_default(),
225        status: outcome.result.log_status().to_string(),
226        added_principals: Vec::new(),
227        removed_principals: Vec::new(),
228        crosses_drive_boundary: false,
229        resolved_folder_id: outcome.resolved_folder_id.clone(),
230        decided_by_folder_id,
231        decided_by_depth,
232        error,
233        duration,
234    });
235}
236
237#[cfg(test)]
238#[allow(clippy::unwrap_used, clippy::expect_used)]
239mod tests {
240    use super::*;
241    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
242    use crate::drive::types::GOOGLE_FOLDER_MIME_TYPE;
243    use crate::drive::write_gate::DriveOperation;
244    use crate::utils::secret::Secret;
245
246    fn test_credentials() -> DriveCredentials {
247        DriveCredentials {
248            client_id: "client-1".to_string(),
249            client_secret: Secret::new("secret-1"),
250            refresh_token: Secret::new("refresh-1"),
251            scope: DriveGrantedScopes::READONLY,
252        }
253    }
254
255    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
256        wiremock::Mock::given(wiremock::matchers::method("POST"))
257            .and(wiremock::matchers::path("/token"))
258            .respond_with(
259                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
260                    "access_token": "test-token",
261                    "expires_in": 3600,
262                })),
263            )
264            .mount(server)
265            .await;
266
267        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
268        crate::drive::client::test_support::replace_session(
269            &mut client,
270            &test_credentials(),
271            &format!("{}/token", server.uri()),
272        );
273        client
274    }
275
276    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
277        let parents_json: Vec<&str> = parents.to_vec();
278        wiremock::Mock::given(wiremock::matchers::method("GET"))
279            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
280            .respond_with(
281                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
282                    "id": id, "name": id, "mimeType": mime_type, "parents": parents_json,
283                })),
284            )
285    }
286
287    fn mount_folder(id: &str) -> wiremock::Mock {
288        wiremock::Mock::given(wiremock::matchers::method("GET"))
289            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
290            .respond_with(
291                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
292                    "id": id, "name": id, "mimeType": GOOGLE_FOLDER_MIME_TYPE,
293                })),
294            )
295    }
296
297    fn opts(dry_run: bool) -> EditOptions {
298        opts_for("file-1", dry_run)
299    }
300
301    fn opts_for(file_id: &str, dry_run: bool) -> EditOptions {
302        EditOptions {
303            file_id: file_id.to_string(),
304            content: b"new content".to_vec(),
305            content_type: "text/plain".to_string(),
306            dry_run,
307        }
308    }
309
310    fn allow_rule() -> FolderPermissionRule {
311        FolderPermissionRule {
312            folder_id: "parent-1".to_string(),
313            recursive: false,
314            allow: std::iter::once(DriveOperation::Edit).collect(),
315            deny: std::collections::HashSet::default(),
316        }
317    }
318
319    #[tokio::test]
320    async fn allowed_target_succeeds_and_calls_edit_endpoint_once() {
321        let server = wiremock::MockServer::start().await;
322        let client = client_with_bootstrapped_token(&server).await;
323        mount_file("file-1", "text/plain", &["parent-1"])
324            .mount(&server)
325            .await;
326        mount_folder("parent-1").mount(&server).await;
327        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
328            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
329            .respond_with(
330                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
331                    "id": "file-1", "name": "file-1",
332                })),
333            )
334            .expect(1)
335            .mount(&server)
336            .await;
337
338        let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
339        assert!(matches!(outcome.result, EditResult::Edited));
340    }
341
342    #[tokio::test]
343    async fn denied_target_refuses_with_zero_edit_calls() {
344        let server = wiremock::MockServer::start().await;
345        let client = client_with_bootstrapped_token(&server).await;
346        mount_file("file-1", "text/plain", &["parent-1"])
347            .mount(&server)
348            .await;
349        mount_folder("parent-1").mount(&server).await;
350        // No PATCH /upload/drive/v3/files/file-1 mock mounted — an
351        // accidental edit attempt fails loudly with "no matching mock".
352
353        let outcome = edit(&client, &opts(false), &[]).await;
354        assert!(matches!(outcome.result, EditResult::Blocked { .. }));
355    }
356
357    #[tokio::test]
358    async fn google_native_document_is_refused_before_any_gate_or_network_call() {
359        let server = wiremock::MockServer::start().await;
360        let client = client_with_bootstrapped_token(&server).await;
361        mount_file(
362            "doc-1",
363            "application/vnd.google-apps.document",
364            &["parent-1"],
365        )
366        .mount(&server)
367        .await;
368        // Deliberately no mock for parent-1 (the gate never runs) and no
369        // PATCH mock — proves the refusal happens strictly before the
370        // ancestor-chain walk and before any mutating call, even though
371        // an allow-everything rule set would otherwise permit it.
372        let permissive_rule = FolderPermissionRule {
373            folder_id: "parent-1".to_string(),
374            recursive: true,
375            allow: std::iter::once(DriveOperation::Edit).collect(),
376            deny: std::collections::HashSet::default(),
377        };
378
379        let outcome = edit(&client, &opts_for("doc-1", false), &[permissive_rule]).await;
380        assert!(matches!(outcome.result, EditResult::RefusedNativeDocument));
381    }
382
383    #[tokio::test]
384    async fn orphan_file_uses_default_policy() {
385        let server = wiremock::MockServer::start().await;
386        let client = client_with_bootstrapped_token(&server).await;
387        mount_file("orphan", "text/plain", &[]).mount(&server).await;
388
389        let outcome = edit(&client, &opts_for("orphan", false), &[]).await;
390        assert!(matches!(outcome.result, EditResult::Blocked { .. }));
391        assert_eq!(outcome.resolved_folder_id, None);
392    }
393
394    #[tokio::test]
395    async fn edit_denies_when_any_current_parent_denies_even_if_another_allows() {
396        let server = wiremock::MockServer::start().await;
397        let client = client_with_bootstrapped_token(&server).await;
398        mount_file("file-1", "text/plain", &["allow-parent", "deny-parent"])
399            .mount(&server)
400            .await;
401        mount_folder("allow-parent").mount(&server).await;
402        mount_folder("deny-parent").mount(&server).await;
403        let rules = [allow_rule()]; // only "parent-1" is allowed; neither
404                                    // allow-parent nor deny-parent match it,
405                                    // so both fall to the default deny —
406                                    // this asserts deny-wins-across-parents
407                                    // even when BOTH parents individually
408                                    // resolve to the same (deny) verdict,
409                                    // and the multi-parent path is exercised.
410
411        let outcome = edit(&client, &opts(false), &rules).await;
412        assert!(matches!(outcome.result, EditResult::Blocked { .. }));
413        assert_eq!(
414            outcome.resolved_folder_id, None,
415            "multi-parent targets report no single resolved folder id"
416        );
417    }
418
419    #[tokio::test]
420    async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
421        let server = wiremock::MockServer::start().await;
422        let client = client_with_bootstrapped_token(&server).await;
423        mount_file("file-1", "text/plain", &["parent-1"])
424            .mount(&server)
425            .await;
426        wiremock::Mock::given(wiremock::matchers::method("GET"))
427            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
428            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
429            .mount(&server)
430            .await;
431
432        let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
433        assert!(
434            matches!(outcome.result, EditResult::Failed { .. }),
435            "a fetch failure must never silently fall through to Edited/WouldEdit"
436        );
437    }
438
439    #[tokio::test]
440    async fn insufficient_scope_403_surfaces_both_write_flags() {
441        let server = wiremock::MockServer::start().await;
442        let client = client_with_bootstrapped_token(&server).await;
443        mount_file("file-1", "text/plain", &["parent-1"])
444            .mount(&server)
445            .await;
446        mount_folder("parent-1").mount(&server).await;
447        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
448            .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
449            .respond_with(
450                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
451                    "error": {
452                        "message": "Insufficient Permission",
453                        "errors": [{"reason": "insufficientPermissions"}],
454                    }
455                })),
456            )
457            .mount(&server)
458            .await;
459
460        let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
461        let EditResult::Failed { detail } = outcome.result else {
462            panic!("expected Failed, got {:?}", outcome.result);
463        };
464        assert!(detail.contains("--write-file"), "{detail}");
465        assert!(detail.contains("--write-full"), "{detail}");
466    }
467
468    #[tokio::test]
469    async fn dry_run_never_calls_edit_endpoint() {
470        let server = wiremock::MockServer::start().await;
471        let client = client_with_bootstrapped_token(&server).await;
472        mount_file("file-1", "text/plain", &["parent-1"])
473            .mount(&server)
474            .await;
475        mount_folder("parent-1").mount(&server).await;
476
477        let outcome = edit(&client, &opts(true), &[allow_rule()]).await;
478        assert!(matches!(outcome.result, EditResult::WouldEdit));
479    }
480
481    #[tokio::test]
482    async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
483        let server = wiremock::MockServer::start().await;
484        let client = client_with_bootstrapped_token(&server).await;
485        mount_file("file-1", "text/plain", &["parent-1"])
486            .expect(2)
487            .mount(&server)
488            .await;
489        mount_folder("parent-1").expect(2).mount(&server).await;
490
491        let dry_run_outcome = edit(&client, &opts(true), &[]).await;
492        let real_outcome = edit(&client, &opts(false), &[]).await;
493        assert!(matches!(dry_run_outcome.result, EditResult::Blocked { .. }));
494        assert!(matches!(real_outcome.result, EditResult::Blocked { .. }));
495    }
496}