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 CreateOptions {
27 pub name: String,
29 pub parent_folder_id: String,
31 pub mime_type: String,
34 pub dry_run: bool,
36}
37
38#[derive(Debug, Clone, Serialize)]
40#[serde(tag = "status", rename_all = "kebab-case")]
41pub enum CreateResult {
42 WouldCreate,
44 Blocked {
46 decided_by: Option<DecidingRule>,
49 },
50 Created {
52 file_id: String,
54 },
55 Failed {
57 detail: String,
59 },
60}
61
62impl CreateResult {
63 fn log_status(&self) -> &'static str {
67 match self {
68 Self::WouldCreate => "would-create",
69 Self::Blocked { .. } => "blocked",
70 Self::Created { .. } => "created",
71 Self::Failed { .. } => "failed",
72 }
73 }
74}
75
76#[derive(Debug, Clone, Serialize)]
78pub struct CreateOutcome {
79 pub name: String,
81 pub parent_folder_id: String,
83 pub result: CreateResult,
85}
86
87impl JsonlSerialize for CreateOutcome {
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 create(
103 client: &DriveClient,
104 opts: &CreateOptions,
105 rules: &[FolderPermissionRule],
106) -> CreateOutcome {
107 let started = Instant::now();
108 let outcome = create_inner(client, opts, rules).await;
109 if !opts.dry_run {
110 record_attempt(&outcome, started.elapsed());
111 }
112 outcome
113}
114
115async fn create_inner(
116 client: &DriveClient,
117 opts: &CreateOptions,
118 rules: &[FolderPermissionRule],
119) -> CreateOutcome {
120 let files_api = FilesApi::new(client);
121 let decision = match folder_ancestry::resolve_decision(
122 &files_api,
123 &opts.parent_folder_id,
124 DriveOperation::Create,
125 rules,
126 )
127 .await
128 {
129 Ok(decision) => decision,
130 Err(err) => {
131 return CreateOutcome {
132 name: opts.name.clone(),
133 parent_folder_id: opts.parent_folder_id.clone(),
134 result: CreateResult::Failed {
135 detail: err.to_string(),
136 },
137 }
138 }
139 };
140 if decision.verdict == write_gate::Verdict::Deny {
141 return CreateOutcome {
142 name: opts.name.clone(),
143 parent_folder_id: opts.parent_folder_id.clone(),
144 result: CreateResult::Blocked {
145 decided_by: decision.decided_by,
146 },
147 };
148 }
149
150 if opts.dry_run {
151 return CreateOutcome {
152 name: opts.name.clone(),
153 parent_folder_id: opts.parent_folder_id.clone(),
154 result: CreateResult::WouldCreate,
155 };
156 }
157
158 let result = match files_api
159 .create(&opts.name, &opts.parent_folder_id, &opts.mime_type)
160 .await
161 {
162 Ok(file) => CreateResult::Created { file_id: file.id },
163 Err(err) => CreateResult::Failed {
164 detail: err.to_string(),
165 },
166 };
167 CreateOutcome {
168 name: opts.name.clone(),
169 parent_folder_id: opts.parent_folder_id.clone(),
170 result,
171 }
172}
173
174fn record_attempt(outcome: &CreateOutcome, duration: Duration) {
176 let error = match &outcome.result {
177 CreateResult::Failed { detail } => Some(detail.clone()),
178 _ => None,
179 };
180 let decided_by = match &outcome.result {
181 CreateResult::Blocked { decided_by } => decided_by.as_ref(),
182 _ => None,
183 };
184 let decided_by = write_gate::decided_by_log_fields(decided_by);
185 request_log::record_drive_mutation(DriveMutationOutcome {
186 operation: "create",
187 file_id: match &outcome.result {
188 CreateResult::Created { file_id } => file_id.clone(),
189 _ => 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) -> CreateOptions {
253 CreateOptions {
254 name: "New File".to_string(),
255 parent_folder_id: "parent-1".to_string(),
256 mime_type: "text/plain".to_string(),
257 dry_run,
258 }
259 }
260
261 fn allow_rule() -> FolderPermissionRule {
262 FolderPermissionRule {
263 folder_id: Some("parent-1".to_string()),
264 file_id: None,
265 recursive: false,
266 allow: std::iter::once(DriveOperation::Create).collect(),
267 deny: std::collections::HashSet::default(),
268 require_lease: true,
269 }
270 }
271
272 #[tokio::test]
273 async fn allowed_target_succeeds_and_calls_create_endpoint_once() {
274 let server = wiremock::MockServer::start().await;
275 let client = client_with_bootstrapped_token(&server).await;
276 mount_parent_folder("parent-1")
277 .respond_with(
278 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
279 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
280 })),
281 )
282 .mount(&server)
283 .await;
284 wiremock::Mock::given(wiremock::matchers::method("POST"))
285 .and(wiremock::matchers::path("/drive/v3/files"))
286 .respond_with(
287 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
288 "id": "new-file-1", "name": "New File",
289 })),
290 )
291 .expect(1)
292 .mount(&server)
293 .await;
294
295 let outcome = create(&client, &opts(false), &[allow_rule()]).await;
296 assert!(matches!(
297 outcome.result,
298 CreateResult::Created { file_id } if file_id == "new-file-1"
299 ));
300 }
301
302 #[tokio::test]
303 async fn denied_target_refuses_with_zero_create_calls() {
304 let server = wiremock::MockServer::start().await;
305 let client = client_with_bootstrapped_token(&server).await;
306 mount_parent_folder("parent-1")
307 .respond_with(
308 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
309 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
310 })),
311 )
312 .mount(&server)
313 .await;
314 let outcome = create(&client, &opts(false), &[]).await;
321 assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
322 }
323
324 #[tokio::test]
325 async fn denied_target_still_writes_a_drivemutation_log_record() {
326 let server = wiremock::MockServer::start().await;
327 let client = client_with_bootstrapped_token(&server).await;
328 mount_parent_folder("parent-1")
329 .respond_with(
330 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
331 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
332 })),
333 )
334 .mount(&server)
335 .await;
336
337 let outcome = create(&client, &opts(false), &[]).await;
344 assert!(!outcome.name.is_empty());
345 assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
346 }
347
348 #[tokio::test]
349 async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
350 let server = wiremock::MockServer::start().await;
351 let client = client_with_bootstrapped_token(&server).await;
352 mount_parent_folder("parent-1")
353 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
354 .mount(&server)
355 .await;
356
357 let outcome = create(&client, &opts(false), &[allow_rule()]).await;
358 assert!(
359 matches!(outcome.result, CreateResult::Failed { .. }),
360 "a fetch failure must never silently fall through to Created/WouldCreate"
361 );
362 }
363
364 #[tokio::test]
365 async fn insufficient_scope_403_surfaces_the_write_file_hint() {
366 let server = wiremock::MockServer::start().await;
367 let client = client_with_bootstrapped_token(&server).await;
368 mount_parent_folder("parent-1")
369 .respond_with(
370 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
371 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
372 })),
373 )
374 .mount(&server)
375 .await;
376 wiremock::Mock::given(wiremock::matchers::method("POST"))
377 .and(wiremock::matchers::path("/drive/v3/files"))
378 .respond_with(
379 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
380 "error": {
381 "message": "Insufficient Permission",
382 "errors": [{"reason": "insufficientPermissions"}],
383 }
384 })),
385 )
386 .mount(&server)
387 .await;
388
389 let outcome = create(&client, &opts(false), &[allow_rule()]).await;
390 let CreateResult::Failed { detail } = outcome.result else {
391 panic!("expected Failed, got {:?}", outcome.result);
392 };
393 assert!(detail.contains("--write-file"), "{detail}");
394 assert!(detail.contains("--write-full"), "{detail}");
395 }
396
397 #[tokio::test]
398 async fn dry_run_never_calls_create_endpoint() {
399 let server = wiremock::MockServer::start().await;
400 let client = client_with_bootstrapped_token(&server).await;
401 mount_parent_folder("parent-1")
402 .respond_with(
403 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
404 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
405 })),
406 )
407 .mount(&server)
408 .await;
409 let outcome = create(&client, &opts(true), &[allow_rule()]).await;
412 assert!(matches!(outcome.result, CreateResult::WouldCreate));
413 }
414
415 #[tokio::test]
416 async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
417 let server = wiremock::MockServer::start().await;
418 let client = client_with_bootstrapped_token(&server).await;
419 mount_parent_folder("parent-1")
420 .respond_with(
421 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
422 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
423 })),
424 )
425 .expect(2)
426 .mount(&server)
427 .await;
428
429 let dry_run_outcome = create(&client, &opts(true), &[]).await;
430 let real_outcome = create(&client, &opts(false), &[]).await;
431 assert!(matches!(
432 dry_run_outcome.result,
433 CreateResult::Blocked { .. }
434 ));
435 assert!(matches!(real_outcome.result, CreateResult::Blocked { .. }));
436 }
437}