Skip to main content

omni_dev/drive/
upload.rs

1//! Drive content upload — creates a new file from local content (issue
2//! #1574, [ADR-0071](../../docs/adrs/adr-0071.md)).
3//!
4//! Structurally identical to `create.rs` (single-target, `rename.rs`'s
5//! linear-function shape, not `file_move.rs`'s batch Plan/Execute) — the
6//! only difference is the mutating call itself (`FilesApi::upload`'s
7//! multipart request instead of `FilesApi::create`'s plain JSON body).
8//! The local-file size check happens in the CLI layer, *before*
9//! `UploadOptions` is even constructed, so it fires identically whether or
10//! not `--dry-run` is set — by the time this module sees `content`, it's
11//! already known to fit.
12
13use std::time::{Duration, Instant};
14
15use serde::Serialize;
16
17use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
18use crate::drive::client::DriveClient;
19use crate::drive::files_api::FilesApi;
20use crate::drive::folder_ancestry;
21use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
22use crate::request_log::{self, DriveMutationOutcome};
23
24/// Per-call upload options.
25#[derive(Debug, Clone)]
26pub struct UploadOptions {
27    /// The new file's display name.
28    pub name: String,
29    /// The folder id to upload it into.
30    pub parent_folder_id: String,
31    /// The file's content, already read into memory (and already
32    /// size-checked) by the caller.
33    pub content: Vec<u8>,
34    /// The content's MIME type.
35    pub content_type: String,
36    /// When `true`, classify but never call `files.create`.
37    pub dry_run: bool,
38}
39
40/// What happened (or, under `--dry-run`, would happen).
41#[derive(Debug, Clone, Serialize)]
42#[serde(tag = "status", rename_all = "kebab-case")]
43pub enum UploadResult {
44    /// `--dry-run`, and the gate would allow it.
45    WouldUpload,
46    /// The folder write-permission gate refused it.
47    Blocked {
48        /// The rule that decided the refusal, if any.
49        decided_by: Option<DecidingRule>,
50    },
51    /// The multipart upload succeeded.
52    Uploaded {
53        /// The newly created file's id.
54        file_id: String,
55    },
56    /// An API/validation error.
57    Failed {
58        /// A human-readable summary of what failed.
59        detail: String,
60    },
61}
62
63impl UploadResult {
64    /// The request-log `status` string — mirrors
65    /// `CreateResult::log_status`/`MoveResult::log_status`'s precedent.
66    fn log_status(&self) -> &'static str {
67        match self {
68            Self::WouldUpload => "would-upload",
69            Self::Blocked { .. } => "blocked",
70            Self::Uploaded { .. } => "uploaded",
71            Self::Failed { .. } => "failed",
72        }
73    }
74}
75
76/// The planned (and, after a real run, final) outcome of one `upload` call.
77#[derive(Debug, Clone, Serialize)]
78pub struct UploadOutcome {
79    /// The requested name.
80    pub name: String,
81    /// The requested parent folder id.
82    pub parent_folder_id: String,
83    /// The result.
84    pub result: UploadResult,
85}
86
87impl JsonlSerialize for UploadOutcome {
88    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<(), anyhow::Error> {
89        write_scalar_jsonl(self, out)
90    }
91}
92
93/// Uploads `opts.content` as `opts.name` inside `opts.parent_folder_id`,
94/// gated by `rules`. See the module doc for why there is no separate
95/// upload-side size check here — the caller already did it.
96///
97/// Every real (non-dry-run) attempt is logged; a `--dry-run` preview never
98/// is, matching `create`/`move`'s existing precedent.
99pub async fn upload(
100    client: &DriveClient,
101    opts: &UploadOptions,
102    rules: &[FolderPermissionRule],
103) -> UploadOutcome {
104    let started = Instant::now();
105    let outcome = upload_inner(client, opts, rules).await;
106    if !opts.dry_run {
107        record_attempt(&outcome, started.elapsed());
108    }
109    outcome
110}
111
112async fn upload_inner(
113    client: &DriveClient,
114    opts: &UploadOptions,
115    rules: &[FolderPermissionRule],
116) -> UploadOutcome {
117    let files_api = FilesApi::new(client);
118    let decision = match folder_ancestry::resolve_decision(
119        &files_api,
120        &opts.parent_folder_id,
121        DriveOperation::Upload,
122        rules,
123    )
124    .await
125    {
126        Ok(decision) => decision,
127        Err(err) => {
128            return UploadOutcome {
129                name: opts.name.clone(),
130                parent_folder_id: opts.parent_folder_id.clone(),
131                result: UploadResult::Failed {
132                    detail: err.to_string(),
133                },
134            }
135        }
136    };
137    if decision.verdict == write_gate::Verdict::Deny {
138        return UploadOutcome {
139            name: opts.name.clone(),
140            parent_folder_id: opts.parent_folder_id.clone(),
141            result: UploadResult::Blocked {
142                decided_by: decision.decided_by,
143            },
144        };
145    }
146
147    if opts.dry_run {
148        return UploadOutcome {
149            name: opts.name.clone(),
150            parent_folder_id: opts.parent_folder_id.clone(),
151            result: UploadResult::WouldUpload,
152        };
153    }
154
155    let result = match files_api
156        .upload(
157            &opts.name,
158            &opts.parent_folder_id,
159            &opts.content,
160            &opts.content_type,
161        )
162        .await
163    {
164        Ok(file) => UploadResult::Uploaded { file_id: file.id },
165        Err(err) => UploadResult::Failed {
166            detail: err.to_string(),
167        },
168    };
169    UploadOutcome {
170        name: opts.name.clone(),
171        parent_folder_id: opts.parent_folder_id.clone(),
172        result,
173    }
174}
175
176/// Builds and writes the [`DriveMutationOutcome`] for one `upload` attempt.
177fn record_attempt(outcome: &UploadOutcome, duration: Duration) {
178    let error = match &outcome.result {
179        UploadResult::Failed { detail } => Some(detail.clone()),
180        _ => None,
181    };
182    let decided_by = match &outcome.result {
183        UploadResult::Blocked { decided_by } => decided_by.as_ref(),
184        _ => None,
185    };
186    let (decided_by_folder_id, decided_by_depth) = write_gate::decided_by_log_fields(decided_by);
187    request_log::record_drive_mutation(DriveMutationOutcome {
188        operation: "upload",
189        file_id: match &outcome.result {
190            UploadResult::Uploaded { file_id } => file_id.clone(),
191            _ => String::new(),
192        },
193        file_name: outcome.name.clone(),
194        status: outcome.result.log_status().to_string(),
195        added_principals: Vec::new(),
196        removed_principals: Vec::new(),
197        crosses_drive_boundary: false,
198        resolved_folder_id: Some(outcome.parent_folder_id.clone()),
199        decided_by_folder_id,
200        decided_by_depth,
201        error,
202        duration,
203    });
204}
205
206#[cfg(test)]
207#[allow(clippy::unwrap_used, clippy::expect_used)]
208mod tests {
209    use super::*;
210    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
211    use crate::drive::types::GOOGLE_FOLDER_MIME_TYPE;
212    use crate::drive::write_gate::DriveOperation;
213    use crate::utils::secret::Secret;
214
215    fn test_credentials() -> DriveCredentials {
216        DriveCredentials {
217            client_id: "client-1".to_string(),
218            client_secret: Secret::new("secret-1"),
219            refresh_token: Secret::new("refresh-1"),
220            scope: DriveGrantedScopes::READONLY,
221        }
222    }
223
224    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
225        wiremock::Mock::given(wiremock::matchers::method("POST"))
226            .and(wiremock::matchers::path("/token"))
227            .respond_with(
228                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
229                    "access_token": "test-token",
230                    "expires_in": 3600,
231                })),
232            )
233            .mount(server)
234            .await;
235
236        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
237        crate::drive::client::test_support::replace_session(
238            &mut client,
239            &test_credentials(),
240            &format!("{}/token", server.uri()),
241        );
242        client
243    }
244
245    fn mount_parent_folder(id: &str) -> wiremock::MockBuilder {
246        wiremock::Mock::given(wiremock::matchers::method("GET"))
247            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
248    }
249
250    fn opts(dry_run: bool) -> UploadOptions {
251        UploadOptions {
252            name: "photo.jpg".to_string(),
253            parent_folder_id: "parent-1".to_string(),
254            content: b"JPEGDATA".to_vec(),
255            content_type: "image/jpeg".to_string(),
256            dry_run,
257        }
258    }
259
260    fn allow_rule() -> FolderPermissionRule {
261        FolderPermissionRule {
262            folder_id: "parent-1".to_string(),
263            recursive: false,
264            allow: std::iter::once(DriveOperation::Upload).collect(),
265            deny: std::collections::HashSet::default(),
266        }
267    }
268
269    #[tokio::test]
270    async fn allowed_target_succeeds_and_calls_upload_endpoint_once() {
271        let server = wiremock::MockServer::start().await;
272        let client = client_with_bootstrapped_token(&server).await;
273        mount_parent_folder("parent-1")
274            .respond_with(
275                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
276                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
277                })),
278            )
279            .mount(&server)
280            .await;
281        wiremock::Mock::given(wiremock::matchers::method("POST"))
282            .and(wiremock::matchers::path("/upload/drive/v3/files"))
283            .respond_with(
284                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
285                    "id": "new-upload-1", "name": "photo.jpg",
286                })),
287            )
288            .expect(1)
289            .mount(&server)
290            .await;
291
292        let outcome = upload(&client, &opts(false), &[allow_rule()]).await;
293        assert!(matches!(
294            outcome.result,
295            UploadResult::Uploaded { file_id } if file_id == "new-upload-1"
296        ));
297    }
298
299    #[tokio::test]
300    async fn denied_target_refuses_with_zero_upload_calls() {
301        let server = wiremock::MockServer::start().await;
302        let client = client_with_bootstrapped_token(&server).await;
303        mount_parent_folder("parent-1")
304            .respond_with(
305                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
306                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
307                })),
308            )
309            .mount(&server)
310            .await;
311        // No POST /upload/drive/v3/files mock mounted — an accidental
312        // upload attempt fails loudly with "no matching mock" instead of
313        // silently succeeding.
314
315        let outcome = upload(&client, &opts(false), &[]).await;
316        assert!(matches!(outcome.result, UploadResult::Blocked { .. }));
317    }
318
319    #[tokio::test]
320    async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
321        let server = wiremock::MockServer::start().await;
322        let client = client_with_bootstrapped_token(&server).await;
323        mount_parent_folder("parent-1")
324            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
325            .mount(&server)
326            .await;
327
328        let outcome = upload(&client, &opts(false), &[allow_rule()]).await;
329        assert!(
330            matches!(outcome.result, UploadResult::Failed { .. }),
331            "a fetch failure must never silently fall through to Uploaded/WouldUpload"
332        );
333    }
334
335    #[tokio::test]
336    async fn insufficient_scope_403_surfaces_the_write_file_hint() {
337        let server = wiremock::MockServer::start().await;
338        let client = client_with_bootstrapped_token(&server).await;
339        mount_parent_folder("parent-1")
340            .respond_with(
341                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
342                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
343                })),
344            )
345            .mount(&server)
346            .await;
347        wiremock::Mock::given(wiremock::matchers::method("POST"))
348            .and(wiremock::matchers::path("/upload/drive/v3/files"))
349            .respond_with(
350                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
351                    "error": {
352                        "message": "Insufficient Permission",
353                        "errors": [{"reason": "insufficientPermissions"}],
354                    }
355                })),
356            )
357            .mount(&server)
358            .await;
359
360        let outcome = upload(&client, &opts(false), &[allow_rule()]).await;
361        let UploadResult::Failed { detail } = outcome.result else {
362            panic!("expected Failed, got {:?}", outcome.result);
363        };
364        assert!(detail.contains("--write-file"), "{detail}");
365        assert!(detail.contains("--write-full"), "{detail}");
366    }
367
368    #[tokio::test]
369    async fn dry_run_never_calls_upload_endpoint() {
370        let server = wiremock::MockServer::start().await;
371        let client = client_with_bootstrapped_token(&server).await;
372        mount_parent_folder("parent-1")
373            .respond_with(
374                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
375                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
376                })),
377            )
378            .mount(&server)
379            .await;
380
381        let outcome = upload(&client, &opts(true), &[allow_rule()]).await;
382        assert!(matches!(outcome.result, UploadResult::WouldUpload));
383    }
384
385    #[tokio::test]
386    async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
387        let server = wiremock::MockServer::start().await;
388        let client = client_with_bootstrapped_token(&server).await;
389        mount_parent_folder("parent-1")
390            .respond_with(
391                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
392                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
393                })),
394            )
395            .expect(2)
396            .mount(&server)
397            .await;
398
399        let dry_run_outcome = upload(&client, &opts(true), &[]).await;
400        let real_outcome = upload(&client, &opts(false), &[]).await;
401        assert!(matches!(
402            dry_run_outcome.result,
403            UploadResult::Blocked { .. }
404        ));
405        assert!(matches!(real_outcome.result, UploadResult::Blocked { .. }));
406    }
407}