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 = 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 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}