1use 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#[derive(Debug, Clone)]
26pub struct UploadOptions {
27 pub name: String,
29 pub parent_folder_id: String,
31 pub content: Vec<u8>,
34 pub content_type: String,
36 pub dry_run: bool,
38}
39
40#[derive(Debug, Clone, Serialize)]
42#[serde(tag = "status", rename_all = "kebab-case")]
43pub enum UploadResult {
44 WouldUpload,
46 Blocked {
48 decided_by: Option<DecidingRule>,
50 },
51 Uploaded {
53 file_id: String,
55 },
56 Failed {
58 detail: String,
60 },
61}
62
63impl UploadResult {
64 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#[derive(Debug, Clone, Serialize)]
78pub struct UploadOutcome {
79 pub name: String,
81 pub parent_folder_id: String,
83 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
93pub 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
176fn 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 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}