1use std::path::PathBuf;
21use std::time::{Duration, Instant};
22
23use serde::Serialize;
24
25use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
26use crate::drive::client::DriveClient;
27use crate::drive::files_api::FilesApi;
28use crate::drive::folder_ancestry;
29use crate::drive::lease::check::{
30 conclude_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
31 LeasedWrite,
32};
33use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
34use crate::request_log::{self, DriveMutationOutcome};
35
36#[derive(Debug, Clone)]
38pub struct EditOptions {
39 pub file_id: String,
41 pub content: Vec<u8>,
44 pub content_type: String,
46 pub dry_run: bool,
48 pub lease_token: Option<String>,
53 pub ledger_path: PathBuf,
57}
58
59#[derive(Debug, Clone, Serialize)]
61#[serde(tag = "status", rename_all = "kebab-case")]
62pub enum EditResult {
63 WouldEdit,
65 RefusedNativeDocument,
70 RefusedNoVisibleParents,
80 Blocked {
82 decided_by: Option<DecidingRule>,
84 },
85 RefusedNoLease,
88 RefusedLeaseExpired,
91 RefusedLeaseWrongFile,
93 RefusedLeaseStale,
96 Edited,
98 Failed {
100 detail: String,
102 },
103}
104
105impl FromLeaseRefusal for EditResult {
106 fn from_no_lease() -> Self {
107 Self::RefusedNoLease
108 }
109 fn from_lease_expired() -> Self {
110 Self::RefusedLeaseExpired
111 }
112 fn from_lease_wrong_file() -> Self {
113 Self::RefusedLeaseWrongFile
114 }
115 fn from_lease_stale() -> Self {
116 Self::RefusedLeaseStale
117 }
118 fn from_lease_failed(detail: String) -> Self {
119 Self::Failed { detail }
120 }
121}
122
123impl EditResult {
124 fn log_status(&self) -> &'static str {
127 match self {
128 Self::WouldEdit => "would-edit",
129 Self::RefusedNativeDocument => "refused-native-document",
130 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
131 Self::Blocked { .. } => "blocked",
132 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
133 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
134 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
135 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
136 Self::Edited => "edited",
137 Self::Failed { .. } => "failed",
138 }
139 }
140}
141
142#[derive(Debug, Clone, Serialize)]
144pub struct EditOutcome {
145 pub file_id: String,
147 pub file_name: Option<String>,
150 pub resolved_folder_id: Option<String>,
160 pub result: EditResult,
162}
163
164impl JsonlSerialize for EditOutcome {
165 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<(), anyhow::Error> {
166 write_scalar_jsonl(self, out)
167 }
168}
169
170pub async fn edit(
175 client: &DriveClient,
176 opts: &EditOptions,
177 rules: &[FolderPermissionRule],
178) -> EditOutcome {
179 let started = Instant::now();
180 let outcome = edit_inner(client, opts, rules).await;
181 if !opts.dry_run {
182 record_attempt(&outcome, started.elapsed());
183 }
184 outcome
185}
186
187async fn edit_inner(
188 client: &DriveClient,
189 opts: &EditOptions,
190 rules: &[FolderPermissionRule],
191) -> EditOutcome {
192 let files_api = FilesApi::new(client);
193 let target = match files_api.get_metadata(&opts.file_id).await {
194 Ok(target) => target,
195 Err(err) => {
196 return EditOutcome {
197 file_id: opts.file_id.clone(),
198 file_name: None,
199 resolved_folder_id: None,
200 result: EditResult::Failed {
201 detail: err.to_string(),
202 },
203 }
204 }
205 };
206
207 if target.is_google_native() {
208 return EditOutcome {
209 file_id: opts.file_id.clone(),
210 file_name: Some(target.name),
211 resolved_folder_id: None,
212 result: EditResult::RefusedNativeDocument,
213 };
214 }
215
216 let evaluated = match folder_ancestry::resolve_decision_for_file_target(
219 &files_api,
220 &target,
221 DriveOperation::Edit,
222 rules,
223 )
224 .await
225 {
226 Ok(evaluated) => evaluated,
227 Err(err) => {
228 return EditOutcome {
229 file_id: opts.file_id.clone(),
230 file_name: Some(target.name),
231 resolved_folder_id: None,
232 result: EditResult::Failed {
233 detail: err.to_string(),
234 },
235 }
236 }
237 };
238
239 if evaluated.source == folder_ancestry::DecisionSource::NoVisibleParents {
240 return EditOutcome {
241 file_id: opts.file_id.clone(),
242 file_name: Some(target.name),
243 resolved_folder_id: None,
244 result: EditResult::RefusedNoVisibleParents,
245 };
246 }
247
248 let folder_ancestry::FileTargetDecision {
249 decision,
250 resolved_folder_id,
251 requires_lease,
252 ..
253 } = evaluated;
254
255 let gated = |result: EditResult| EditOutcome {
256 file_id: opts.file_id.clone(),
257 file_name: Some(target.name.clone()),
258 resolved_folder_id: resolved_folder_id.clone(),
259 result,
260 };
261
262 if decision.verdict == write_gate::Verdict::Deny {
263 return gated(EditResult::Blocked {
264 decided_by: decision.decided_by,
265 });
266 }
267
268 if opts.dry_run {
269 return gated(EditResult::WouldEdit);
270 }
271
272 let leased = LeasedWrite {
314 log_prefix: "drive edit",
315 operation: "edit",
316 ledger_path: &opts.ledger_path,
317 file_id: &opts.file_id,
318 };
319 let lease_grant = match gate_optional_leased_write(
320 leased,
321 &files_api,
322 requires_lease,
323 opts.lease_token.as_deref(),
324 )
325 .await
326 {
327 Ok(grant) => grant,
328 Err(err) => return gated(err.into_result()),
329 };
330
331 let result = match conclude_leased_write(
332 leased,
333 &lease_grant,
334 files_api
335 .edit_content(&opts.file_id, &opts.content, &opts.content_type)
336 .await,
337 |updated| (updated.version.clone(), updated.modified_time.clone()),
338 ToString::to_string,
339 ) {
340 Ok(_updated) => EditResult::Edited,
341 Err(err) => EditResult::Failed {
342 detail: err.to_string(),
343 },
344 };
345 drop(lease_grant);
346 gated(result)
347}
348
349fn record_attempt(outcome: &EditOutcome, duration: Duration) {
351 let error = match &outcome.result {
352 EditResult::Failed { detail } => Some(detail.clone()),
353 _ => None,
354 };
355 let decided_by = match &outcome.result {
356 EditResult::Blocked { decided_by } => decided_by.as_ref(),
357 _ => None,
358 };
359 let decided_by = write_gate::decided_by_log_fields(decided_by);
360 request_log::record_drive_mutation(DriveMutationOutcome {
361 operation: "edit",
362 file_id: outcome.file_id.clone(),
363 file_name: outcome.file_name.clone().unwrap_or_default(),
364 status: outcome.result.log_status().to_string(),
365 added_principals: Vec::new(),
366 removed_principals: Vec::new(),
367 crosses_drive_boundary: false,
368 resolved_folder_id: outcome.resolved_folder_id.clone(),
369 decided_by_folder_id: decided_by.folder_id,
370 decided_by_depth: decided_by.depth,
371 decided_by_file_id: decided_by.file_id,
372 error,
373 duration,
374 ..Default::default()
375 });
376}
377
378#[cfg(test)]
379#[allow(clippy::unwrap_used, clippy::expect_used)]
380mod tests {
381 use super::*;
382 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
383 use crate::drive::lease::ledger::LeaseLedger;
384 use crate::drive::test_support::seed_lease;
385 use crate::drive::types::GOOGLE_FOLDER_MIME_TYPE;
386 use crate::drive::write_gate::DriveOperation;
387 use crate::test_support::AuditLogGuard;
388 use crate::utils::secret::Secret;
389
390 fn test_credentials() -> DriveCredentials {
391 DriveCredentials {
392 client_id: "client-1".to_string(),
393 client_secret: Secret::new("secret-1"),
394 refresh_token: Secret::new("refresh-1"),
395 scope: DriveGrantedScopes::READONLY,
396 }
397 }
398
399 async fn client_with_bootstrapped_token(server: &wiremock::MockServer) -> DriveClient {
400 wiremock::Mock::given(wiremock::matchers::method("POST"))
401 .and(wiremock::matchers::path("/token"))
402 .respond_with(
403 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
404 "access_token": "test-token",
405 "expires_in": 3600,
406 })),
407 )
408 .mount(server)
409 .await;
410
411 let mut client = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
412 crate::drive::client::test_support::replace_session(
413 &mut client,
414 &test_credentials(),
415 &format!("{}/token", server.uri()),
416 );
417 client
418 }
419
420 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
424 let parents_json: Vec<&str> = parents.to_vec();
425 wiremock::Mock::given(wiremock::matchers::method("GET"))
426 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
427 .respond_with(
428 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
429 "id": id, "name": id, "mimeType": mime_type, "parents": parents_json,
430 "version": "1",
431 })),
432 )
433 }
434
435 fn mount_folder(id: &str) -> wiremock::Mock {
436 wiremock::Mock::given(wiremock::matchers::method("GET"))
437 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
438 .respond_with(
439 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
440 "id": id, "name": id, "mimeType": GOOGLE_FOLDER_MIME_TYPE,
441 })),
442 )
443 }
444
445 fn opts(dry_run: bool) -> EditOptions {
446 opts_for("file-1", dry_run)
447 }
448
449 fn opts_for(file_id: &str, dry_run: bool) -> EditOptions {
454 EditOptions {
455 file_id: file_id.to_string(),
456 content: b"new content".to_vec(),
457 content_type: "text/plain".to_string(),
458 dry_run,
459 lease_token: None,
460 ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
461 }
462 }
463
464 fn opts_with_lease(file_id: &str, ledger_path: &std::path::Path, version: &str) -> EditOptions {
468 let token = seed_lease(ledger_path, file_id, version);
469 EditOptions {
470 lease_token: Some(token),
471 ledger_path: ledger_path.to_path_buf(),
472 ..opts_for(file_id, false)
473 }
474 }
475
476 fn allow_rule() -> FolderPermissionRule {
477 FolderPermissionRule {
478 folder_id: Some("parent-1".to_string()),
479 file_id: None,
480 recursive: false,
481 allow: std::iter::once(DriveOperation::Edit).collect(),
482 deny: std::collections::HashSet::default(),
483 require_lease: true,
484 }
485 }
486
487 #[tokio::test]
488 async fn allowed_target_succeeds_and_calls_edit_endpoint_once() {
489 let server = wiremock::MockServer::start().await;
490 let client = client_with_bootstrapped_token(&server).await;
491 mount_file("file-1", "text/plain", &["parent-1"])
492 .mount(&server)
493 .await;
494 mount_folder("parent-1").mount(&server).await;
495 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
496 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
497 .respond_with(
498 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
499 "id": "file-1", "name": "file-1", "version": "2",
500 })),
501 )
502 .expect(1)
503 .mount(&server)
504 .await;
505 let dir = tempfile::tempdir().unwrap();
506 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
507 let ledger_path = dir.path().join("lease-ledger.jsonl");
508
509 let outcome = edit(
510 &client,
511 &opts_with_lease("file-1", &ledger_path, "1"),
512 &[allow_rule()],
513 )
514 .await;
515 assert!(matches!(outcome.result, EditResult::Edited));
516
517 let reloaded = LeaseLedger::load(&ledger_path).unwrap();
518 assert_eq!(
519 reloaded.get("test-lease-token").unwrap().version,
520 "2",
521 "a successful write refreshes the lease's recorded version"
522 );
523 }
524
525 #[tokio::test]
526 async fn staleness_check_uses_a_version_fetched_after_the_ancestor_walk_not_before() {
527 let server = wiremock::MockServer::start().await;
536 let client = client_with_bootstrapped_token(&server).await;
537 wiremock::Mock::given(wiremock::matchers::method("GET"))
538 .and(wiremock::matchers::path("/drive/v3/files/file-1"))
539 .respond_with(
540 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
541 "id": "file-1", "name": "file-1", "mimeType": "text/plain",
542 "parents": ["parent-1"], "version": "0",
543 })),
544 )
545 .up_to_n_times(1)
546 .with_priority(1)
547 .mount(&server)
548 .await;
549 wiremock::Mock::given(wiremock::matchers::method("GET"))
550 .and(wiremock::matchers::path("/drive/v3/files/file-1"))
551 .respond_with(
552 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
553 "id": "file-1", "name": "file-1", "mimeType": "text/plain",
554 "parents": ["parent-1"], "version": "1",
555 })),
556 )
557 .with_priority(2)
558 .mount(&server)
559 .await;
560 mount_folder("parent-1").mount(&server).await;
561 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
562 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
563 .respond_with(
564 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
565 "id": "file-1", "name": "file-1", "version": "2",
566 })),
567 )
568 .expect(1)
569 .mount(&server)
570 .await;
571 let dir = tempfile::tempdir().unwrap();
572 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
573 let ledger_path = dir.path().join("lease-ledger.jsonl");
574
575 let outcome = edit(
576 &client,
577 &opts_with_lease("file-1", &ledger_path, "1"),
578 &[allow_rule()],
579 )
580 .await;
581 assert!(
582 matches!(outcome.result, EditResult::Edited),
583 "{:?}",
584 outcome.result
585 );
586 }
587
588 #[tokio::test]
589 async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_edit_call() {
590 let server = wiremock::MockServer::start().await;
595 let client = client_with_bootstrapped_token(&server).await;
596 mount_file("file-1", "text/plain", &["parent-1"])
597 .up_to_n_times(1)
598 .with_priority(1)
599 .mount(&server)
600 .await;
601 wiremock::Mock::given(wiremock::matchers::method("GET"))
602 .and(wiremock::matchers::path("/drive/v3/files/file-1"))
603 .respond_with(wiremock::ResponseTemplate::new(500))
604 .with_priority(2)
605 .mount(&server)
606 .await;
607 mount_folder("parent-1").mount(&server).await;
608 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
609 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
610 .respond_with(wiremock::ResponseTemplate::new(200))
611 .expect(0)
612 .mount(&server)
613 .await;
614 let dir = tempfile::tempdir().unwrap();
615 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
616 let ledger_path = dir.path().join("lease-ledger.jsonl");
617
618 let outcome = edit(
619 &client,
620 &opts_with_lease("file-1", &ledger_path, "1"),
621 &[allow_rule()],
622 )
623 .await;
624 assert!(
625 matches!(outcome.result, EditResult::Failed { .. }),
626 "{:?}",
627 outcome.result
628 );
629 assert_eq!(outcome.file_name.as_deref(), Some("file-1"));
630 }
631
632 #[cfg(unix)]
638 struct MakeDirReadOnlyThenRespond {
639 dir: std::path::PathBuf,
640 }
641
642 #[cfg(unix)]
643 impl wiremock::Respond for MakeDirReadOnlyThenRespond {
644 fn respond(&self, _req: &wiremock::Request) -> wiremock::ResponseTemplate {
645 use std::os::unix::fs::PermissionsExt;
646 std::fs::set_permissions(&self.dir, std::fs::Permissions::from_mode(0o500)).unwrap();
647 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
648 "id": "file-1", "name": "file-1", "version": "2",
649 }))
650 }
651 }
652
653 #[cfg(unix)]
657 #[tokio::test]
658 async fn a_failed_lease_refresh_after_a_successful_write_does_not_fail_the_edit() {
659 use std::os::unix::fs::PermissionsExt;
660
661 let server = wiremock::MockServer::start().await;
662 let client = client_with_bootstrapped_token(&server).await;
663 mount_file("file-1", "text/plain", &["parent-1"])
664 .mount(&server)
665 .await;
666 mount_folder("parent-1").mount(&server).await;
667 let root = tempfile::tempdir().unwrap();
668 let _audit = crate::test_support::AuditLogGuard::redirect(root.path());
669 let ledger_dir = root.path().join("ledger");
670 std::fs::create_dir(&ledger_dir).unwrap();
671 let ledger_path = ledger_dir.join("lease-ledger.jsonl");
672 let opts = opts_with_lease("file-1", &ledger_path, "1");
673 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
674 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
675 .respond_with(MakeDirReadOnlyThenRespond {
676 dir: ledger_dir.clone(),
677 })
678 .expect(1)
679 .mount(&server)
680 .await;
681
682 let outcome = edit(&client, &opts, &[allow_rule()]).await;
683
684 std::fs::set_permissions(&ledger_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
687
688 assert!(
689 matches!(outcome.result, EditResult::Edited),
690 "a refresh failure must not fail the edit itself: {:?}",
691 outcome.result
692 );
693 }
694
695 #[tokio::test]
696 async fn denied_target_refuses_with_zero_edit_calls() {
697 let server = wiremock::MockServer::start().await;
698 let client = client_with_bootstrapped_token(&server).await;
699 mount_file("file-1", "text/plain", &["parent-1"])
700 .mount(&server)
701 .await;
702 mount_folder("parent-1").mount(&server).await;
703 let outcome = edit(&client, &opts(false), &[]).await;
707 assert!(matches!(outcome.result, EditResult::Blocked { .. }));
708 }
709
710 #[tokio::test]
711 async fn google_native_document_is_refused_before_any_gate_or_network_call() {
712 let server = wiremock::MockServer::start().await;
713 let client = client_with_bootstrapped_token(&server).await;
714 mount_file(
715 "doc-1",
716 "application/vnd.google-apps.document",
717 &["parent-1"],
718 )
719 .mount(&server)
720 .await;
721 let permissive_rule = FolderPermissionRule {
726 folder_id: Some("parent-1".to_string()),
727 file_id: None,
728 recursive: true,
729 allow: std::iter::once(DriveOperation::Edit).collect(),
730 deny: std::collections::HashSet::default(),
731 require_lease: true,
732 };
733
734 let outcome = edit(&client, &opts_for("doc-1", false), &[permissive_rule]).await;
735 assert!(matches!(outcome.result, EditResult::RefusedNativeDocument));
736 }
737
738 #[tokio::test]
739 async fn orphan_file_is_refused_as_having_no_visible_parents() {
740 let server = wiremock::MockServer::start().await;
745 let client = client_with_bootstrapped_token(&server).await;
746 mount_file("orphan", "text/plain", &[]).mount(&server).await;
747
748 let outcome = edit(&client, &opts_for("orphan", false), &[]).await;
749 assert!(matches!(
750 outcome.result,
751 EditResult::RefusedNoVisibleParents
752 ));
753 assert_eq!(outcome.resolved_folder_id, None);
754 }
755
756 #[tokio::test]
757 async fn edit_denies_when_any_current_parent_denies_even_if_another_allows() {
758 let server = wiremock::MockServer::start().await;
759 let client = client_with_bootstrapped_token(&server).await;
760 mount_file("file-1", "text/plain", &["allow-parent", "deny-parent"])
761 .mount(&server)
762 .await;
763 mount_folder("allow-parent").mount(&server).await;
764 mount_folder("deny-parent").mount(&server).await;
765 let rules = [allow_rule()]; let outcome = edit(&client, &opts(false), &rules).await;
774 assert!(matches!(outcome.result, EditResult::Blocked { .. }));
775 assert_eq!(
776 outcome.resolved_folder_id, None,
777 "multi-parent targets report no single resolved folder id"
778 );
779 }
780
781 #[tokio::test]
782 async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
783 let server = wiremock::MockServer::start().await;
784 let client = client_with_bootstrapped_token(&server).await;
785 mount_file("file-1", "text/plain", &["parent-1"])
786 .mount(&server)
787 .await;
788 wiremock::Mock::given(wiremock::matchers::method("GET"))
789 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
790 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("server error"))
791 .mount(&server)
792 .await;
793
794 let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
795 assert!(
796 matches!(outcome.result, EditResult::Failed { .. }),
797 "a fetch failure must never silently fall through to Edited/WouldEdit"
798 );
799 }
800
801 #[tokio::test]
802 async fn insufficient_scope_403_surfaces_both_write_flags() {
803 let server = wiremock::MockServer::start().await;
804 let client = client_with_bootstrapped_token(&server).await;
805 mount_file("file-1", "text/plain", &["parent-1"])
806 .mount(&server)
807 .await;
808 mount_folder("parent-1").mount(&server).await;
809 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
810 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
811 .respond_with(
812 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
813 "error": {
814 "message": "Insufficient Permission",
815 "errors": [{"reason": "insufficientPermissions"}],
816 }
817 })),
818 )
819 .mount(&server)
820 .await;
821 let dir = tempfile::tempdir().unwrap();
822 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
823 let ledger_path = dir.path().join("lease-ledger.jsonl");
824
825 let outcome = edit(
826 &client,
827 &opts_with_lease("file-1", &ledger_path, "1"),
828 &[allow_rule()],
829 )
830 .await;
831 let EditResult::Failed { detail } = outcome.result else {
832 panic!("expected Failed, got {:?}", outcome.result);
833 };
834 assert!(detail.contains("--write-file"), "{detail}");
835 assert!(detail.contains("--write-full"), "{detail}");
836 }
837
838 #[tokio::test]
839 async fn dry_run_never_calls_edit_endpoint() {
840 let server = wiremock::MockServer::start().await;
841 let client = client_with_bootstrapped_token(&server).await;
842 mount_file("file-1", "text/plain", &["parent-1"])
843 .mount(&server)
844 .await;
845 mount_folder("parent-1").mount(&server).await;
846
847 let outcome = edit(&client, &opts(true), &[allow_rule()]).await;
848 assert!(matches!(outcome.result, EditResult::WouldEdit));
849 }
850
851 #[tokio::test]
852 async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
853 let server = wiremock::MockServer::start().await;
854 let client = client_with_bootstrapped_token(&server).await;
855 mount_file("file-1", "text/plain", &["parent-1"])
856 .expect(2)
857 .mount(&server)
858 .await;
859 mount_folder("parent-1").expect(2).mount(&server).await;
860
861 let dry_run_outcome = edit(&client, &opts(true), &[]).await;
862 let real_outcome = edit(&client, &opts(false), &[]).await;
863 assert!(matches!(dry_run_outcome.result, EditResult::Blocked { .. }));
864 assert!(matches!(real_outcome.result, EditResult::Blocked { .. }));
865 }
866
867 #[tokio::test]
870 async fn a_file_rule_grants_a_target_with_no_visible_parents() {
871 let server = wiremock::MockServer::start().await;
875 let client = client_with_bootstrapped_token(&server).await;
876 mount_file("file-1", "text/plain", &[]).mount(&server).await;
877 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
878 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
879 .respond_with(
880 wiremock::ResponseTemplate::new(200)
881 .set_body_json(serde_json::json!({"id": "file-1", "name": "file-1"})),
882 )
883 .expect(1)
884 .mount(&server)
885 .await;
886 let dir = tempfile::tempdir().unwrap();
887 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
888 let ledger_path = dir.path().join("lease-ledger.jsonl");
889
890 let outcome = edit(
891 &client,
892 &opts_with_lease("file-1", &ledger_path, "1"),
893 &[FolderPermissionRule::file("file-1").allowing([DriveOperation::Edit])],
894 )
895 .await;
896
897 assert!(
898 matches!(outcome.result, EditResult::Edited),
899 "{:?}",
900 outcome.result
901 );
902 }
903
904 #[tokio::test]
905 async fn a_parentless_target_is_now_refused_distinctly_not_generically_blocked() {
906 let server = wiremock::MockServer::start().await;
909 let client = client_with_bootstrapped_token(&server).await;
910 mount_file("file-1", "text/plain", &[]).mount(&server).await;
911
912 let outcome = edit(&client, &opts(false), &[]).await;
913
914 assert!(matches!(
915 outcome.result,
916 EditResult::RefusedNoVisibleParents
917 ));
918 assert_eq!(outcome.result.log_status(), "refused-no-visible-parents");
919 }
920
921 #[tokio::test]
922 async fn a_file_deny_beats_an_allowing_parent_folder() {
923 let server = wiremock::MockServer::start().await;
924 let client = client_with_bootstrapped_token(&server).await;
925 mount_file("file-1", "text/plain", &["parent-1"])
926 .mount(&server)
927 .await;
928 let outcome = edit(
932 &client,
933 &opts(false),
934 &[
935 allow_rule(),
936 FolderPermissionRule::file("file-1").denying([DriveOperation::Edit]),
937 ],
938 )
939 .await;
940
941 match &outcome.result {
942 EditResult::Blocked { decided_by } => {
943 let rule = decided_by.as_ref().expect("a file rule decided this");
944 assert_eq!(rule.kind_label(), "file");
945 assert_eq!(rule.id(), "file-1");
946 }
947 other => panic!("expected Blocked, got {other:?}"),
948 }
949 }
950
951 #[tokio::test]
954 async fn refuses_without_a_lease_when_the_rule_requires_one() {
955 let server = wiremock::MockServer::start().await;
956 let client = client_with_bootstrapped_token(&server).await;
957 mount_file("file-1", "text/plain", &["parent-1"])
958 .mount(&server)
959 .await;
960 mount_folder("parent-1").mount(&server).await;
961 let dir = tempfile::tempdir().unwrap();
963 let _audit = AuditLogGuard::redirect(dir.path());
964
965 let outcome = edit(&client, &opts(false), &[allow_rule()]).await;
966 assert!(matches!(outcome.result, EditResult::RefusedNoLease));
967 assert_eq!(outcome.result.log_status(), "refused-no-lease");
968 }
969
970 #[tokio::test]
971 async fn refuses_an_unknown_lease_token() {
972 let server = wiremock::MockServer::start().await;
973 let client = client_with_bootstrapped_token(&server).await;
974 mount_file("file-1", "text/plain", &["parent-1"])
975 .mount(&server)
976 .await;
977 mount_folder("parent-1").mount(&server).await;
978 let dir = tempfile::tempdir().unwrap();
979 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
980 let ledger_path = dir.path().join("lease-ledger.jsonl");
981 let opts = EditOptions {
984 lease_token: Some("bogus-token".to_string()),
985 ledger_path,
986 ..opts_for("file-1", false)
987 };
988 let outcome = edit(&client, &opts, &[allow_rule()]).await;
989 assert!(matches!(outcome.result, EditResult::RefusedLeaseExpired));
990 }
991
992 #[tokio::test]
993 async fn refuses_a_lease_when_the_ledger_is_unreadable() {
994 let server = wiremock::MockServer::start().await;
999 let client = client_with_bootstrapped_token(&server).await;
1000 mount_file("file-1", "text/plain", &["parent-1"])
1001 .mount(&server)
1002 .await;
1003 mount_folder("parent-1").mount(&server).await;
1004 let dir = tempfile::tempdir().unwrap();
1005 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1006 let ledger_path = dir.path().join("lease-ledger.jsonl");
1007 std::fs::create_dir(&ledger_path).unwrap();
1008
1009 let opts = EditOptions {
1010 lease_token: Some("any-token".to_string()),
1011 ledger_path,
1012 ..opts_for("file-1", false)
1013 };
1014 let outcome = edit(&client, &opts, &[allow_rule()]).await;
1015 assert!(matches!(outcome.result, EditResult::RefusedLeaseExpired));
1016 }
1017
1018 #[tokio::test]
1019 async fn refuses_an_expired_lease() {
1020 let server = wiremock::MockServer::start().await;
1021 let client = client_with_bootstrapped_token(&server).await;
1022 mount_file("file-1", "text/plain", &["parent-1"])
1023 .mount(&server)
1024 .await;
1025 mount_folder("parent-1").mount(&server).await;
1026 let dir = tempfile::tempdir().unwrap();
1027 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1028 let ledger_path = dir.path().join("lease-ledger.jsonl");
1029 let token = "expired-token".to_string();
1030 let mut ledger = LeaseLedger::default();
1031 ledger.insert(crate::drive::lease::ledger::LeaseRecord {
1032 token: token.clone(),
1033 file_id: "file-1".to_string(),
1034 version: "1".to_string(),
1035 modified_time: None,
1036 backup: crate::drive::lease::ledger::LeaseBackup::Bytes {
1037 path: std::path::PathBuf::from("/tmp/test-backup"),
1038 sha256: "deadbeef".to_string(),
1039 size: 0,
1040 },
1041 acquired_at: chrono::Utc::now() - chrono::Duration::hours(2),
1042 expires_at: chrono::Utc::now() - chrono::Duration::hours(1),
1043 released_at: None,
1044 superseded_by: None,
1045 restored_at: None,
1046 restored_sheet_id: None,
1047 });
1048 ledger.save(&ledger_path).unwrap();
1049
1050 let opts = EditOptions {
1051 lease_token: Some(token),
1052 ledger_path,
1053 ..opts_for("file-1", false)
1054 };
1055 let outcome = edit(&client, &opts, &[allow_rule()]).await;
1056 assert!(matches!(outcome.result, EditResult::RefusedLeaseExpired));
1057 }
1058
1059 #[tokio::test]
1060 async fn refuses_a_lease_bound_to_a_different_file() {
1061 let server = wiremock::MockServer::start().await;
1062 let client = client_with_bootstrapped_token(&server).await;
1063 mount_file("file-1", "text/plain", &["parent-1"])
1064 .mount(&server)
1065 .await;
1066 mount_folder("parent-1").mount(&server).await;
1067 let dir = tempfile::tempdir().unwrap();
1068 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1069 let ledger_path = dir.path().join("lease-ledger.jsonl");
1070 let token = seed_lease(&ledger_path, "some-other-file", "1");
1072
1073 let opts = EditOptions {
1074 lease_token: Some(token),
1075 ledger_path,
1076 ..opts_for("file-1", false)
1077 };
1078 let outcome = edit(&client, &opts, &[allow_rule()]).await;
1079 assert!(matches!(outcome.result, EditResult::RefusedLeaseWrongFile));
1080 }
1081
1082 #[tokio::test]
1083 async fn refuses_a_stale_lease_when_the_file_has_moved() {
1084 let server = wiremock::MockServer::start().await;
1085 let client = client_with_bootstrapped_token(&server).await;
1086 mount_file("file-1", "text/plain", &["parent-1"])
1089 .mount(&server)
1090 .await;
1091 mount_folder("parent-1").mount(&server).await;
1092 let dir = tempfile::tempdir().unwrap();
1093 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1094 let ledger_path = dir.path().join("lease-ledger.jsonl");
1095 let token = seed_lease(&ledger_path, "file-1", "0");
1096
1097 let opts = EditOptions {
1098 lease_token: Some(token),
1099 ledger_path,
1100 ..opts_for("file-1", false)
1101 };
1102 let outcome = edit(&client, &opts, &[allow_rule()]).await;
1103 assert!(matches!(outcome.result, EditResult::RefusedLeaseStale));
1104 }
1105
1106 #[tokio::test]
1107 async fn edit_requires_a_lease_when_any_legacy_parent_requires_one_even_if_the_deciding_parent_opted_out(
1108 ) {
1109 let server = wiremock::MockServer::start().await;
1116 let client = client_with_bootstrapped_token(&server).await;
1117 mount_file("file-1", "text/plain", &["strict-parent", "lenient-parent"])
1118 .mount(&server)
1119 .await;
1120 mount_folder("strict-parent").mount(&server).await;
1121 mount_folder("lenient-parent").mount(&server).await;
1122 let dir = tempfile::tempdir().unwrap();
1124 let _audit = AuditLogGuard::redirect(dir.path());
1125 let rules = [
1126 FolderPermissionRule::folder("strict-parent").allowing([DriveOperation::Edit]),
1127 FolderPermissionRule::folder("lenient-parent")
1128 .allowing([DriveOperation::Edit])
1129 .requiring_lease(false),
1130 ];
1131
1132 let outcome = edit(&client, &opts(false), &rules).await;
1133 assert!(
1134 matches!(outcome.result, EditResult::RefusedNoLease),
1135 "{:?}",
1136 outcome.result
1137 );
1138 }
1139
1140 #[tokio::test]
1141 async fn a_held_ledger_lock_fails_the_edit_rather_than_risking_a_lease_double_spend() {
1142 let server = wiremock::MockServer::start().await;
1147 let client = client_with_bootstrapped_token(&server).await;
1148 mount_file("file-1", "text/plain", &["parent-1"])
1149 .mount(&server)
1150 .await;
1151 mount_folder("parent-1").mount(&server).await;
1152 let dir = tempfile::tempdir().unwrap();
1153 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1154 let ledger_path = dir.path().join("lease-ledger.jsonl");
1155 let token = seed_lease(&ledger_path, "file-1", "1");
1156 let mut lock_path = ledger_path.clone().into_os_string();
1162 lock_path.push(".lock");
1163 std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
1164 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1165 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
1166 .respond_with(wiremock::ResponseTemplate::new(200))
1167 .expect(0)
1168 .mount(&server)
1169 .await;
1170
1171 let opts = EditOptions {
1172 lease_token: Some(token),
1173 ledger_path,
1174 ..opts_for("file-1", false)
1175 };
1176 let outcome = edit(&client, &opts, &[allow_rule()]).await;
1177 assert!(
1178 matches!(outcome.result, EditResult::Failed { .. }),
1179 "{:?}",
1180 outcome.result
1181 );
1182 }
1183
1184 #[tokio::test]
1185 async fn require_lease_false_writes_without_a_lease() {
1186 let server = wiremock::MockServer::start().await;
1187 let client = client_with_bootstrapped_token(&server).await;
1188 mount_file("file-1", "text/plain", &["parent-1"])
1189 .mount(&server)
1190 .await;
1191 mount_folder("parent-1").mount(&server).await;
1192 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1193 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
1194 .respond_with(
1195 wiremock::ResponseTemplate::new(200)
1196 .set_body_json(serde_json::json!({"id": "file-1", "name": "file-1"})),
1197 )
1198 .expect(1)
1199 .mount(&server)
1200 .await;
1201 let rule = FolderPermissionRule::folder("parent-1")
1202 .allowing([DriveOperation::Edit])
1203 .requiring_lease(false);
1204
1205 let outcome = edit(&client, &opts(false), &[rule]).await;
1207 assert!(matches!(outcome.result, EditResult::Edited));
1208 }
1209
1210 #[tokio::test]
1211 async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
1212 let server = wiremock::MockServer::start().await;
1216 let client = client_with_bootstrapped_token(&server).await;
1217 mount_file("file-1", "text/plain", &["parent-1"])
1220 .mount(&server)
1221 .await;
1222 mount_folder("parent-1").mount(&server).await;
1223 let dir = tempfile::tempdir().unwrap();
1225 let _audit = AuditLogGuard::redirect(dir.path());
1226 let ledger_path = dir.path().join("lease-ledger.jsonl");
1227 let token = seed_lease(&ledger_path, "file-1", "0");
1228 let rule = FolderPermissionRule::folder("parent-1")
1229 .allowing([DriveOperation::Edit])
1230 .requiring_lease(false);
1231
1232 let opts = EditOptions {
1233 lease_token: Some(token),
1234 ledger_path,
1235 ..opts_for("file-1", false)
1236 };
1237 let outcome = edit(&client, &opts, &[rule]).await;
1238 assert!(
1239 matches!(outcome.result, EditResult::RefusedLeaseStale),
1240 "{:?}",
1241 outcome.result
1242 );
1243 }
1244
1245 #[tokio::test]
1246 async fn require_lease_false_still_refreshes_the_ledger_for_a_presented_lease() {
1247 let server = wiremock::MockServer::start().await;
1254 let client = client_with_bootstrapped_token(&server).await;
1255 mount_file("file-1", "text/plain", &["parent-1"])
1256 .mount(&server)
1257 .await;
1258 mount_folder("parent-1").mount(&server).await;
1259 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1260 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
1261 .respond_with(
1262 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1263 "id": "file-1", "name": "file-1", "version": "2",
1264 })),
1265 )
1266 .mount(&server)
1267 .await;
1268 let dir = tempfile::tempdir().unwrap();
1269 let _audit = AuditLogGuard::redirect(dir.path());
1270 let ledger_path = dir.path().join("lease-ledger.jsonl");
1271 let token = seed_lease(&ledger_path, "file-1", "1");
1272 let rule = FolderPermissionRule::folder("parent-1")
1273 .allowing([DriveOperation::Edit])
1274 .requiring_lease(false);
1275
1276 let opts = EditOptions {
1277 lease_token: Some(token.clone()),
1278 ledger_path: ledger_path.clone(),
1279 ..opts_for("file-1", false)
1280 };
1281 let outcome = edit(&client, &opts, &[rule]).await;
1282 assert!(
1283 matches!(outcome.result, EditResult::Edited),
1284 "{:?}",
1285 outcome.result
1286 );
1287
1288 let ledger = LeaseLedger::load(&ledger_path).unwrap();
1289 let record = ledger
1290 .get(&token)
1291 .expect("the lease is still in the ledger");
1292 assert_eq!(record.version, "2");
1293 }
1294
1295 #[tokio::test]
1296 async fn a_dry_run_never_needs_a_lease() {
1297 let server = wiremock::MockServer::start().await;
1298 let client = client_with_bootstrapped_token(&server).await;
1299 mount_file("file-1", "text/plain", &["parent-1"])
1300 .mount(&server)
1301 .await;
1302 mount_folder("parent-1").mount(&server).await;
1303 let outcome = edit(&client, &opts(true), &[allow_rule()]).await;
1307 assert!(matches!(outcome.result, EditResult::WouldEdit));
1308 }
1309
1310 #[tokio::test]
1313 async fn a_successful_edit_writes_a_pending_intent_and_an_allowed_outcome() {
1314 let server = wiremock::MockServer::start().await;
1315 let client = client_with_bootstrapped_token(&server).await;
1316 mount_file("file-1", "text/plain", &["parent-1"])
1317 .mount(&server)
1318 .await;
1319 mount_folder("parent-1").mount(&server).await;
1320 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1321 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
1322 .respond_with(
1323 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1324 "id": "file-1", "name": "file-1", "version": "2",
1325 })),
1326 )
1327 .mount(&server)
1328 .await;
1329 let dir = tempfile::tempdir().unwrap();
1330 let audit = AuditLogGuard::redirect(dir.path());
1331 let ledger_path = dir.path().join("lease-ledger.jsonl");
1332
1333 let outcome = edit(
1334 &client,
1335 &opts_with_lease("file-1", &ledger_path, "1"),
1336 &[allow_rule()],
1337 )
1338 .await;
1339 assert!(matches!(outcome.result, EditResult::Edited));
1340
1341 let records = audit.records();
1342 assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
1343 for record in &records {
1346 assert_eq!(record.command, ["drive", "edit"]);
1347 assert_eq!(
1348 record.context.get("lease_id").map(String::as_str),
1349 Some("test-lease-token")
1350 );
1351 }
1352 assert_eq!(
1353 records[0].context.get("version_before").map(String::as_str),
1354 Some("1")
1355 );
1356 assert_eq!(
1357 records[1].context.get("version_after").map(String::as_str),
1358 Some("2")
1359 );
1360 assert_eq!(records[1].error, None);
1361 }
1362
1363 #[tokio::test]
1364 async fn a_failed_edit_after_a_valid_lease_writes_a_failed_outcome() {
1365 let server = wiremock::MockServer::start().await;
1366 let client = client_with_bootstrapped_token(&server).await;
1367 mount_file("file-1", "text/plain", &["parent-1"])
1368 .mount(&server)
1369 .await;
1370 mount_folder("parent-1").mount(&server).await;
1371 wiremock::Mock::given(wiremock::matchers::method("PATCH"))
1372 .and(wiremock::matchers::path("/upload/drive/v3/files/file-1"))
1373 .respond_with(wiremock::ResponseTemplate::new(500))
1374 .mount(&server)
1375 .await;
1376 let dir = tempfile::tempdir().unwrap();
1377 let audit = AuditLogGuard::redirect(dir.path());
1378 let ledger_path = dir.path().join("lease-ledger.jsonl");
1379
1380 let outcome = edit(
1381 &client,
1382 &opts_with_lease("file-1", &ledger_path, "1"),
1383 &[allow_rule()],
1384 )
1385 .await;
1386 let EditResult::Failed { detail } = &outcome.result else {
1387 panic!("expected Failed, got {:?}", outcome.result);
1388 };
1389
1390 let records = audit.records();
1391 assert_eq!(audit.verdicts(), ["pending", "failed"], "{records:?}");
1392 assert_eq!(records[1].error.as_deref(), Some(detail.as_str()));
1394 }
1395}