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_folder_id, decided_by_depth) = 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,
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) -> CreateOptions {
251 CreateOptions {
252 name: "New File".to_string(),
253 parent_folder_id: "parent-1".to_string(),
254 mime_type: "text/plain".to_string(),
255 dry_run,
256 }
257 }
258
259 fn allow_rule() -> FolderPermissionRule {
260 FolderPermissionRule {
261 folder_id: "parent-1".to_string(),
262 recursive: false,
263 allow: std::iter::once(DriveOperation::Create).collect(),
264 deny: std::collections::HashSet::default(),
265 }
266 }
267
268 #[tokio::test]
269 async fn allowed_target_succeeds_and_calls_create_endpoint_once() {
270 let server = wiremock::MockServer::start().await;
271 let client = client_with_bootstrapped_token(&server).await;
272 mount_parent_folder("parent-1")
273 .respond_with(
274 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
275 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
276 })),
277 )
278 .mount(&server)
279 .await;
280 wiremock::Mock::given(wiremock::matchers::method("POST"))
281 .and(wiremock::matchers::path("/drive/v3/files"))
282 .respond_with(
283 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
284 "id": "new-file-1", "name": "New File",
285 })),
286 )
287 .expect(1)
288 .mount(&server)
289 .await;
290
291 let outcome = create(&client, &opts(false), &[allow_rule()]).await;
292 assert!(matches!(
293 outcome.result,
294 CreateResult::Created { file_id } if file_id == "new-file-1"
295 ));
296 }
297
298 #[tokio::test]
299 async fn denied_target_refuses_with_zero_create_calls() {
300 let server = wiremock::MockServer::start().await;
301 let client = client_with_bootstrapped_token(&server).await;
302 mount_parent_folder("parent-1")
303 .respond_with(
304 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
305 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
306 })),
307 )
308 .mount(&server)
309 .await;
310 let outcome = create(&client, &opts(false), &[]).await;
317 assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
318 }
319
320 #[tokio::test]
321 async fn denied_target_still_writes_a_drivemutation_log_record() {
322 let server = wiremock::MockServer::start().await;
323 let client = client_with_bootstrapped_token(&server).await;
324 mount_parent_folder("parent-1")
325 .respond_with(
326 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
327 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
328 })),
329 )
330 .mount(&server)
331 .await;
332
333 let outcome = create(&client, &opts(false), &[]).await;
340 assert!(!outcome.name.is_empty());
341 assert!(matches!(outcome.result, CreateResult::Blocked { .. }));
342 }
343
344 #[tokio::test]
345 async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
346 let server = wiremock::MockServer::start().await;
347 let client = client_with_bootstrapped_token(&server).await;
348 mount_parent_folder("parent-1")
349 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
350 .mount(&server)
351 .await;
352
353 let outcome = create(&client, &opts(false), &[allow_rule()]).await;
354 assert!(
355 matches!(outcome.result, CreateResult::Failed { .. }),
356 "a fetch failure must never silently fall through to Created/WouldCreate"
357 );
358 }
359
360 #[tokio::test]
361 async fn insufficient_scope_403_surfaces_the_write_file_hint() {
362 let server = wiremock::MockServer::start().await;
363 let client = client_with_bootstrapped_token(&server).await;
364 mount_parent_folder("parent-1")
365 .respond_with(
366 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
367 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
368 })),
369 )
370 .mount(&server)
371 .await;
372 wiremock::Mock::given(wiremock::matchers::method("POST"))
373 .and(wiremock::matchers::path("/drive/v3/files"))
374 .respond_with(
375 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
376 "error": {
377 "message": "Insufficient Permission",
378 "errors": [{"reason": "insufficientPermissions"}],
379 }
380 })),
381 )
382 .mount(&server)
383 .await;
384
385 let outcome = create(&client, &opts(false), &[allow_rule()]).await;
386 let CreateResult::Failed { detail } = outcome.result else {
387 panic!("expected Failed, got {:?}", outcome.result);
388 };
389 assert!(detail.contains("--write-file"), "{detail}");
390 assert!(detail.contains("--write-full"), "{detail}");
391 }
392
393 #[tokio::test]
394 async fn dry_run_never_calls_create_endpoint() {
395 let server = wiremock::MockServer::start().await;
396 let client = client_with_bootstrapped_token(&server).await;
397 mount_parent_folder("parent-1")
398 .respond_with(
399 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
400 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
401 })),
402 )
403 .mount(&server)
404 .await;
405 let outcome = create(&client, &opts(true), &[allow_rule()]).await;
408 assert!(matches!(outcome.result, CreateResult::WouldCreate));
409 }
410
411 #[tokio::test]
412 async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
413 let server = wiremock::MockServer::start().await;
414 let client = client_with_bootstrapped_token(&server).await;
415 mount_parent_folder("parent-1")
416 .respond_with(
417 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
418 "id": "parent-1", "name": "parent-1", "mimeType": GOOGLE_FOLDER_MIME_TYPE,
419 })),
420 )
421 .expect(2)
422 .mount(&server)
423 .await;
424
425 let dry_run_outcome = create(&client, &opts(true), &[]).await;
426 let real_outcome = create(&client, &opts(false), &[]).await;
427 assert!(matches!(
428 dry_run_outcome.result,
429 CreateResult::Blocked { .. }
430 ));
431 assert!(matches!(real_outcome.result, CreateResult::Blocked { .. }));
432 }
433}