1use std::time::{Duration, Instant};
19
20use serde::Serialize;
21
22use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
23use crate::drive::client::DriveClient;
24use crate::drive::files_api::FilesApi;
25use crate::drive::folder_ancestry;
26use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
27use crate::request_log::{self, DriveMutationOutcome};
28
29#[derive(Debug, Clone)]
31pub struct EditOptions {
32 pub file_id: String,
34 pub content: Vec<u8>,
37 pub content_type: String,
39 pub dry_run: bool,
41}
42
43#[derive(Debug, Clone, Serialize)]
45#[serde(tag = "status", rename_all = "kebab-case")]
46pub enum EditResult {
47 WouldEdit,
49 RefusedNativeDocument,
54 Blocked {
56 decided_by: Option<DecidingRule>,
58 },
59 Edited,
61 Failed {
63 detail: String,
65 },
66}
67
68impl EditResult {
69 fn log_status(&self) -> &'static str {
72 match self {
73 Self::WouldEdit => "would-edit",
74 Self::RefusedNativeDocument => "refused-native-document",
75 Self::Blocked { .. } => "blocked",
76 Self::Edited => "edited",
77 Self::Failed { .. } => "failed",
78 }
79 }
80}
81
82#[derive(Debug, Clone, Serialize)]
84pub struct EditOutcome {
85 pub file_id: String,
87 pub file_name: Option<String>,
90 pub resolved_folder_id: Option<String>,
96 pub result: EditResult,
98}
99
100impl JsonlSerialize for EditOutcome {
101 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<(), anyhow::Error> {
102 write_scalar_jsonl(self, out)
103 }
104}
105
106pub async fn edit(
111 client: &DriveClient,
112 opts: &EditOptions,
113 rules: &[FolderPermissionRule],
114) -> EditOutcome {
115 let started = Instant::now();
116 let outcome = edit_inner(client, opts, rules).await;
117 if !opts.dry_run {
118 record_attempt(&outcome, started.elapsed());
119 }
120 outcome
121}
122
123async fn edit_inner(
124 client: &DriveClient,
125 opts: &EditOptions,
126 rules: &[FolderPermissionRule],
127) -> EditOutcome {
128 let files_api = FilesApi::new(client);
129 let target = match files_api.get_metadata(&opts.file_id).await {
130 Ok(target) => target,
131 Err(err) => {
132 return EditOutcome {
133 file_id: opts.file_id.clone(),
134 file_name: None,
135 resolved_folder_id: None,
136 result: EditResult::Failed {
137 detail: err.to_string(),
138 },
139 }
140 }
141 };
142
143 if target.is_google_native() {
144 return EditOutcome {
145 file_id: opts.file_id.clone(),
146 file_name: Some(target.name),
147 resolved_folder_id: None,
148 result: EditResult::RefusedNativeDocument,
149 };
150 }
151
152 let (decision, resolved_folder_id) = match folder_ancestry::resolve_decision_for_parents(
153 &files_api,
154 &target.parents,
155 DriveOperation::Edit,
156 rules,
157 )
158 .await
159 {
160 Ok(evaluated) => evaluated,
161 Err(err) => {
162 return EditOutcome {
163 file_id: opts.file_id.clone(),
164 file_name: Some(target.name),
165 resolved_folder_id: None,
166 result: EditResult::Failed {
167 detail: err.to_string(),
168 },
169 }
170 }
171 };
172
173 if decision.verdict == write_gate::Verdict::Deny {
174 return EditOutcome {
175 file_id: opts.file_id.clone(),
176 file_name: Some(target.name),
177 resolved_folder_id,
178 result: EditResult::Blocked {
179 decided_by: decision.decided_by,
180 },
181 };
182 }
183
184 if opts.dry_run {
185 return EditOutcome {
186 file_id: opts.file_id.clone(),
187 file_name: Some(target.name),
188 resolved_folder_id,
189 result: EditResult::WouldEdit,
190 };
191 }
192
193 let result = match files_api
194 .edit_content(&opts.file_id, &opts.content, &opts.content_type)
195 .await
196 {
197 Ok(_) => EditResult::Edited,
198 Err(err) => EditResult::Failed {
199 detail: err.to_string(),
200 },
201 };
202 EditOutcome {
203 file_id: opts.file_id.clone(),
204 file_name: Some(target.name),
205 resolved_folder_id,
206 result,
207 }
208}
209
210fn record_attempt(outcome: &EditOutcome, duration: Duration) {
212 let error = match &outcome.result {
213 EditResult::Failed { detail } => Some(detail.clone()),
214 _ => None,
215 };
216 let decided_by = match &outcome.result {
217 EditResult::Blocked { decided_by } => decided_by.as_ref(),
218 _ => None,
219 };
220 let (decided_by_folder_id, decided_by_depth) = write_gate::decided_by_log_fields(decided_by);
221 request_log::record_drive_mutation(DriveMutationOutcome {
222 operation: "edit",
223 file_id: outcome.file_id.clone(),
224 file_name: outcome.file_name.clone().unwrap_or_default(),
225 status: outcome.result.log_status().to_string(),
226 added_principals: Vec::new(),
227 removed_principals: Vec::new(),
228 crosses_drive_boundary: false,
229 resolved_folder_id: outcome.resolved_folder_id.clone(),
230 decided_by_folder_id,
231 decided_by_depth,
232 error,
233 duration,
234 });
235}
236
237#[cfg(test)]
238#[allow(clippy::unwrap_used, clippy::expect_used)]
239mod tests {
240 use super::*;
241 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
242 use crate::drive::types::GOOGLE_FOLDER_MIME_TYPE;
243 use crate::drive::write_gate::DriveOperation;
244 use crate::utils::secret::Secret;
245
246 fn test_credentials() -> DriveCredentials {
247 DriveCredentials {
248 client_id: "client-1".to_string(),
249 client_secret: Secret::new("secret-1"),
250 refresh_token: Secret::new("refresh-1"),
251 scope: DriveGrantedScopes::READONLY,
252 }
253 }
254
255 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
256 wiremock::Mock::given(wiremock::matchers::method("POST"))
257 .and(wiremock::matchers::path("/token"))
258 .respond_with(
259 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
260 "access_token": "test-token",
261 "expires_in": 3600,
262 })),
263 )
264 .mount(server)
265 .await;
266
267 let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
268 crate::drive::client::test_support::replace_session(
269 &mut client,
270 &test_credentials(),
271 &format!("{}/token", server.uri()),
272 );
273 client
274 }
275
276 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
277 let parents_json: Vec<&str> = parents.to_vec();
278 wiremock::Mock::given(wiremock::matchers::method("GET"))
279 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
280 .respond_with(
281 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
282 "id": id, "name": id, "mimeType": mime_type, "parents": parents_json,
283 })),
284 )
285 }
286
287 fn mount_folder(id: &str) -> wiremock::Mock {
288 wiremock::Mock::given(wiremock::matchers::method("GET"))
289 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
290 .respond_with(
291 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
292 "id": id, "name": id, "mimeType": GOOGLE_FOLDER_MIME_TYPE,
293 })),
294 )
295 }
296
297 fn opts(dry_run: bool) -> EditOptions {
298 opts_for("file-1", dry_run)
299 }
300
301 fn opts_for(file_id: &str, dry_run: bool) -> EditOptions {
302 EditOptions {
303 file_id: file_id.to_string(),
304 content: b"new content".to_vec(),
305 content_type: "text/plain".to_string(),
306 dry_run,
307 }
308 }
309
310 fn allow_rule() -> FolderPermissionRule {
311 FolderPermissionRule {
312 folder_id: "parent-1".to_string(),
313 recursive: false,
314 allow: std::iter::once(DriveOperation::Edit).collect(),
315 deny: std::collections::HashSet::default(),
316 }
317 }
318
319 #[tokio::test]
320 async fn allowed_target_succeeds_and_calls_edit_endpoint_once() {
321 let server = wiremock::MockServer::start().await;
322 let client = client_with_bootstrapped_token(&server).await;
323 mount_file("file-1", "text/plain", &["parent-1"])
324 .mount(&server)
325 .await;
326 mount_folder("parent-1").mount(&server).await;
327 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
328 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
329 .respond_with(
330 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
331 "id": "file-1", "name": "file-1",
332 })),
333 )
334 .expect(1)
335 .mount(&server)
336 .await;
337
338 let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
339 assert!(matches!(outcome.result, EditResult::Edited));
340 }
341
342 #[tokio::test]
343 async fn denied_target_refuses_with_zero_edit_calls() {
344 let server = wiremock::MockServer::start().await;
345 let client = client_with_bootstrapped_token(&server).await;
346 mount_file("file-1", "text/plain", &["parent-1"])
347 .mount(&server)
348 .await;
349 mount_folder("parent-1").mount(&server).await;
350 let outcome = edit(&client, &opts(false), &[]).await;
354 assert!(matches!(outcome.result, EditResult::Blocked { .. }));
355 }
356
357 #[tokio::test]
358 async fn google_native_document_is_refused_before_any_gate_or_network_call() {
359 let server = wiremock::MockServer::start().await;
360 let client = client_with_bootstrapped_token(&server).await;
361 mount_file(
362 "doc-1",
363 "application/vnd.google-apps.document",
364 &["parent-1"],
365 )
366 .mount(&server)
367 .await;
368 let permissive_rule = FolderPermissionRule {
373 folder_id: "parent-1".to_string(),
374 recursive: true,
375 allow: std::iter::once(DriveOperation::Edit).collect(),
376 deny: std::collections::HashSet::default(),
377 };
378
379 let outcome = edit(&client, &opts_for("doc-1", false), &[permissive_rule]).await;
380 assert!(matches!(outcome.result, EditResult::RefusedNativeDocument));
381 }
382
383 #[tokio::test]
384 async fn orphan_file_uses_default_policy() {
385 let server = wiremock::MockServer::start().await;
386 let client = client_with_bootstrapped_token(&server).await;
387 mount_file("orphan", "text/plain", &[]).mount(&server).await;
388
389 let outcome = edit(&client, &opts_for("orphan", false), &[]).await;
390 assert!(matches!(outcome.result, EditResult::Blocked { .. }));
391 assert_eq!(outcome.resolved_folder_id, None);
392 }
393
394 #[tokio::test]
395 async fn edit_denies_when_any_current_parent_denies_even_if_another_allows() {
396 let server = wiremock::MockServer::start().await;
397 let client = client_with_bootstrapped_token(&server).await;
398 mount_file("file-1", "text/plain", &["allow-parent", "deny-parent"])
399 .mount(&server)
400 .await;
401 mount_folder("allow-parent").mount(&server).await;
402 mount_folder("deny-parent").mount(&server).await;
403 let rules = [allow_rule()]; let outcome = edit(&client, &opts(false), &rules).await;
412 assert!(matches!(outcome.result, EditResult::Blocked { .. }));
413 assert_eq!(
414 outcome.resolved_folder_id, None,
415 "multi-parent targets report no single resolved folder id"
416 );
417 }
418
419 #[tokio::test]
420 async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
421 let server = wiremock::MockServer::start().await;
422 let client = client_with_bootstrapped_token(&server).await;
423 mount_file("file-1", "text/plain", &["parent-1"])
424 .mount(&server)
425 .await;
426 wiremock::Mock::given(wiremock::matchers::method("GET"))
427 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
428 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
429 .mount(&server)
430 .await;
431
432 let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
433 assert!(
434 matches!(outcome.result, EditResult::Failed { .. }),
435 "a fetch failure must never silently fall through to Edited/WouldEdit"
436 );
437 }
438
439 #[tokio::test]
440 async fn insufficient_scope_403_surfaces_both_write_flags() {
441 let server = wiremock::MockServer::start().await;
442 let client = client_with_bootstrapped_token(&server).await;
443 mount_file("file-1", "text/plain", &["parent-1"])
444 .mount(&server)
445 .await;
446 mount_folder("parent-1").mount(&server).await;
447 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
448 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
449 .respond_with(
450 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
451 "error": {
452 "message": "Insufficient Permission",
453 "errors": [{"reason": "insufficientPermissions"}],
454 }
455 })),
456 )
457 .mount(&server)
458 .await;
459
460 let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
461 let EditResult::Failed { detail } = outcome.result else {
462 panic!("expected Failed, got {:?}", outcome.result);
463 };
464 assert!(detail.contains("--write-file"), "{detail}");
465 assert!(detail.contains("--write-full"), "{detail}");
466 }
467
468 #[tokio::test]
469 async fn dry_run_never_calls_edit_endpoint() {
470 let server = wiremock::MockServer::start().await;
471 let client = client_with_bootstrapped_token(&server).await;
472 mount_file("file-1", "text/plain", &["parent-1"])
473 .mount(&server)
474 .await;
475 mount_folder("parent-1").mount(&server).await;
476
477 let outcome = edit(&client, &opts(true), &[allow_rule()]).await;
478 assert!(matches!(outcome.result, EditResult::WouldEdit));
479 }
480
481 #[tokio::test]
482 async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
483 let server = wiremock::MockServer::start().await;
484 let client = client_with_bootstrapped_token(&server).await;
485 mount_file("file-1", "text/plain", &["parent-1"])
486 .expect(2)
487 .mount(&server)
488 .await;
489 mount_folder("parent-1").expect(2).mount(&server).await;
490
491 let dry_run_outcome = edit(&client, &opts(true), &[]).await;
492 let real_outcome = edit(&client, &opts(false), &[]).await;
493 assert!(matches!(dry_run_outcome.result, EditResult::Blocked { .. }));
494 assert!(matches!(real_outcome.result, EditResult::Blocked { .. }));
495 }
496}