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 = 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: decided_by.folder_id,
200        decided_by_depth: decided_by.depth,
201        decided_by_file_id: decided_by.file_id,
202        error,
203        duration,
204        ..Default::default()
205    });
206}
207
208#[cfg(test)]
209#[allow(clippy::unwrap_used, clippy::expect_used)]
210mod tests {
211    use super::*;
212    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
213    use crate::drive::types::GOOGLE_FOLDER_MIME_TYPE;
214    use crate::drive::write_gate::DriveOperation;
215    use crate::utils::secret::Secret;
216
217    fn test_credentials() -> DriveCredentials {
218        DriveCredentials {
219            client_id: "client-1".to_string(),
220            client_secret: Secret::new("secret-1"),
221            refresh_token: Secret::new("refresh-1"),
222            scope: DriveGrantedScopes::READONLY,
223        }
224    }
225
226    async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
227        wiremock::Mock::given(wiremock::matchers::method("POST"))
228            .and(wiremock::matchers::path("/token"))
229            .respond_with(
230                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
231                    "access_token": "test-token",
232                    "expires_in": 3600,
233                })),
234            )
235            .mount(server)
236            .await;
237
238        let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
239        crate::drive::client::test_support::replace_session(
240            &mut client,
241            &test_credentials(),
242            &format!("{}/token", server.uri()),
243        );
244        client
245    }
246
247    fn mount_parent_folder(id: &str) -> wiremock::MockBuilder {
248        wiremock::Mock::given(wiremock::matchers::method("GET"))
249            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
250    }
251
252    fn opts(dry_run: bool) -> UploadOptions {
253        UploadOptions {
254            name: "photo.jpg".to_string(),
255            parent_folder_id: "parent-1".to_string(),
256            content: b"JPEGDATA".to_vec(),
257            content_type: "image/jpeg".to_string(),
258            dry_run,
259        }
260    }
261
262    fn allow_rule() -> FolderPermissionRule {
263        FolderPermissionRule {
264            folder_id: Some("parent-1".to_string()),
265            file_id: None,
266            recursive: false,
267            allow: std::iter::once(DriveOperation::Upload).collect(),
268            deny: std::collections::HashSet::default(),
269            require_lease: true,
270        }
271    }
272
273    #[tokio::test]
274    async fn allowed_target_succeeds_and_calls_upload_endpoint_once() {
275        let server = wiremock::MockServer::start().await;
276        let client = client_with_bootstrapped_token(&server).await;
277        mount_parent_folder("parent-1")
278            .respond_with(
279                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
280                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
281                })),
282            )
283            .mount(&server)
284            .await;
285        wiremock::Mock::given(wiremock::matchers::method("POST"))
286            .and(wiremock::matchers::path("/upload/drive/v3/files"))
287            .respond_with(
288                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
289                    "id": "new-upload-1", "name": "photo.jpg",
290                })),
291            )
292            .expect(1)
293            .mount(&server)
294            .await;
295
296        let outcome = upload(&client, &opts(false), &[allow_rule()]).await;
297        assert!(matches!(
298            outcome.result,
299            UploadResult::Uploaded { file_id } if file_id == "new-upload-1"
300        ));
301    }
302
303    #[tokio::test]
304    async fn denied_target_refuses_with_zero_upload_calls() {
305        let server = wiremock::MockServer::start().await;
306        let client = client_with_bootstrapped_token(&server).await;
307        mount_parent_folder("parent-1")
308            .respond_with(
309                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
310                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
311                })),
312            )
313            .mount(&server)
314            .await;
315        // No POST /upload/drive/v3/files mock mounted — an accidental
316        // upload attempt fails loudly with "no matching mock" instead of
317        // silently succeeding.
318
319        let outcome = upload(&client, &opts(false), &[]).await;
320        assert!(matches!(outcome.result, UploadResult::Blocked { .. }));
321    }
322
323    #[tokio::test]
324    async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
325        let server = wiremock::MockServer::start().await;
326        let client = client_with_bootstrapped_token(&server).await;
327        mount_parent_folder("parent-1")
328            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
329            .mount(&server)
330            .await;
331
332        let outcome = upload(&client, &opts(false), &[allow_rule()]).await;
333        assert!(
334            matches!(outcome.result, UploadResult::Failed { .. }),
335            "a fetch failure must never silently fall through to Uploaded/WouldUpload"
336        );
337    }
338
339    #[tokio::test]
340    async fn insufficient_scope_403_surfaces_the_write_file_hint() {
341        let server = wiremock::MockServer::start().await;
342        let client = client_with_bootstrapped_token(&server).await;
343        mount_parent_folder("parent-1")
344            .respond_with(
345                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
346                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
347                })),
348            )
349            .mount(&server)
350            .await;
351        wiremock::Mock::given(wiremock::matchers::method("POST"))
352            .and(wiremock::matchers::path("/upload/drive/v3/files"))
353            .respond_with(
354                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
355                    "error": {
356                        "message": "Insufficient Permission",
357                        "errors": [{"reason": "insufficientPermissions"}],
358                    }
359                })),
360            )
361            .mount(&server)
362            .await;
363
364        let outcome = upload(&client, &opts(false), &[allow_rule()]).await;
365        let UploadResult::Failed { detail } = outcome.result else {
366            panic!("expected Failed, got {:?}", outcome.result);
367        };
368        assert!(detail.contains("--write-file"), "{detail}");
369        assert!(detail.contains("--write-full"), "{detail}");
370    }
371
372    #[tokio::test]
373    async fn dry_run_never_calls_upload_endpoint() {
374        let server = wiremock::MockServer::start().await;
375        let client = client_with_bootstrapped_token(&server).await;
376        mount_parent_folder("parent-1")
377            .respond_with(
378                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
379                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
380                })),
381            )
382            .mount(&server)
383            .await;
384
385        let outcome = upload(&client, &opts(true), &[allow_rule()]).await;
386        assert!(matches!(outcome.result, UploadResult::WouldUpload));
387    }
388
389    #[tokio::test]
390    async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
391        let server = wiremock::MockServer::start().await;
392        let client = client_with_bootstrapped_token(&server).await;
393        mount_parent_folder("parent-1")
394            .respond_with(
395                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
396                    "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
397                })),
398            )
399            .expect(2)
400            .mount(&server)
401            .await;
402
403        let dry_run_outcome = upload(&client, &opts(true), &[]).await;
404        let real_outcome = upload(&client, &opts(false), &[]).await;
405        assert!(matches!(
406            dry_run_outcome.result,
407            UploadResult::Blocked { .. }
408        ));
409        assert!(matches!(real_outcome.result, UploadResult::Blocked { .. }));
410    }
411}