use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
use crate::drive::client::DriveClient;
use crate::drive::docs::api::{is_stale_revision, DocsApi, SuggestionsViewMode};
use crate::drive::docs::client::DocsClient;
use crate::drive::docs::write_types::DocsRequest;
use crate::drive::files_api::FilesApi;
use crate::drive::folder_ancestry;
use crate::drive::lease::check::{
conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
LeasedWrite,
};
use crate::drive::types::{GOOGLE_DOC_MIME_TYPE, GOOGLE_SHORTCUT_MIME_TYPE};
use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
use crate::request_log::{self, DriveMutationOutcome};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WritePayload {
Replace {
search: String,
replace: String,
match_case: bool,
},
Append {
text: String,
},
}
impl WritePayload {
#[must_use]
pub const fn verb(&self) -> WriteVerb {
match self {
Self::Replace { .. } => WriteVerb::Replace,
Self::Append { .. } => WriteVerb::Append,
}
}
fn validate(&self) -> Result<(), String> {
match self {
Self::Replace { search, .. } if search.is_empty() => {
Err("--search cannot be empty".to_string())
}
Self::Append { text } if text.is_empty() => {
Err("nothing to append: the text is empty".to_string())
}
_ => Ok(()),
}
}
fn to_request(&self) -> DocsRequest {
match self {
Self::Replace {
search,
replace,
match_case,
} => DocsRequest::replace_all_text(search, replace, *match_case),
Self::Append { text } => DocsRequest::insert_text_at_end(text),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteVerb {
Replace,
Append,
}
impl WriteVerb {
const fn log_operation(self) -> &'static str {
match self {
Self::Replace => "docs-replace",
Self::Append => "docs-append",
}
}
const fn label(self) -> &'static str {
match self {
Self::Replace => "replace",
Self::Append => "append",
}
}
}
#[derive(Debug, Clone)]
pub struct WriteOptions {
pub document_id: String,
pub payload: WritePayload,
pub dry_run: bool,
pub lease_token: Option<String>,
pub ledger_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum WriteResult {
WouldReplace {
occurrences: usize,
},
WouldAppend {
#[serde(skip_serializing_if = "Option::is_none")]
document_end_index: Option<i64>,
chars: usize,
bytes: usize,
},
RefusedNotADocument {
mime_type: String,
},
RefusedShortcut,
RefusedNoVisibleParents,
RefusedNoRevisionId,
Blocked {
decided_by: Option<DecidingRule>,
},
RefusedNoLease,
RefusedLeaseExpired,
RefusedLeaseWrongFile,
RefusedLeaseStale,
Replaced {
occurrences_changed: i64,
},
Appended {
chars: usize,
bytes: usize,
},
StaleRevision {
required_revision_id: String,
detail: String,
},
Failed {
detail: String,
},
}
impl FromLeaseRefusal for WriteResult {
fn from_no_lease() -> Self {
Self::RefusedNoLease
}
fn from_lease_expired() -> Self {
Self::RefusedLeaseExpired
}
fn from_lease_wrong_file() -> Self {
Self::RefusedLeaseWrongFile
}
fn from_lease_stale() -> Self {
Self::RefusedLeaseStale
}
fn from_lease_failed(detail: String) -> Self {
Self::Failed { detail }
}
}
impl WriteResult {
fn log_status(&self) -> &'static str {
match self {
Self::WouldReplace { .. } => "would-replace",
Self::WouldAppend { .. } => "would-append",
Self::RefusedNotADocument { .. } => "refused-not-a-document",
Self::RefusedShortcut => "refused-shortcut",
Self::RefusedNoVisibleParents => "refused-no-visible-parents",
Self::RefusedNoRevisionId => "refused-no-revision-id",
Self::Blocked { .. } => "blocked",
Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
Self::Replaced { .. } => "replaced",
Self::Appended { .. } => "appended",
Self::StaleRevision { .. } => "stale-revision",
Self::Failed { .. } => "failed",
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct WriteOutcome {
pub document_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_folder_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required_revision_id: Option<String>,
pub result: WriteResult,
}
impl JsonlSerialize for WriteOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
write_scalar_jsonl(self, out)
}
}
#[must_use]
pub fn count_occurrences(haystack: &str, needle: &str, match_case: bool) -> usize {
if needle.is_empty() {
return 0;
}
if match_case {
return haystack.matches(needle).count();
}
haystack
.to_lowercase()
.matches(&needle.to_lowercase())
.count()
}
pub async fn write(
drive: &DriveClient,
docs: &DocsClient,
opts: &WriteOptions,
rules: &[FolderPermissionRule],
) -> WriteOutcome {
let started = Instant::now();
let outcome = write_inner(drive, docs, opts, rules).await;
if !opts.dry_run {
record_attempt(&outcome, opts, started.elapsed());
}
outcome
}
async fn write_inner(
drive: &DriveClient,
docs: &DocsClient,
opts: &WriteOptions,
rules: &[FolderPermissionRule],
) -> WriteOutcome {
let bare = |result| WriteOutcome {
document_id: opts.document_id.clone(),
file_name: None,
resolved_folder_id: None,
required_revision_id: None,
result,
};
if let Err(detail) = opts.payload.validate() {
return bare(WriteResult::Failed { detail });
}
let files_api = FilesApi::new(drive);
let target = match files_api.get_metadata(&opts.document_id).await {
Ok(target) => target,
Err(err) => {
return bare(WriteResult::Failed {
detail: err.to_string(),
})
}
};
let with_target = |result| WriteOutcome {
document_id: opts.document_id.clone(),
file_name: Some(target.name.clone()),
resolved_folder_id: None,
required_revision_id: None,
result,
};
if target.mime_type == GOOGLE_SHORTCUT_MIME_TYPE {
return with_target(WriteResult::RefusedShortcut);
}
if target.mime_type != GOOGLE_DOC_MIME_TYPE {
return with_target(WriteResult::RefusedNotADocument {
mime_type: target.mime_type.clone(),
});
}
let evaluated = match folder_ancestry::resolve_decision_for_file_target(
&files_api,
&target,
DriveOperation::DocsWrite,
rules,
)
.await
{
Ok(evaluated) => evaluated,
Err(err) => {
return with_target(WriteResult::Failed {
detail: err.to_string(),
})
}
};
if evaluated.source == folder_ancestry::DecisionSource::NoVisibleParents {
return with_target(WriteResult::RefusedNoVisibleParents);
}
let folder_ancestry::FileTargetDecision {
decision,
resolved_folder_id,
requires_lease,
..
} = evaluated;
let gated = |result, revision: Option<String>| WriteOutcome {
document_id: opts.document_id.clone(),
file_name: Some(target.name.clone()),
resolved_folder_id: resolved_folder_id.clone(),
required_revision_id: revision,
result,
};
if decision.verdict == write_gate::Verdict::Deny {
return gated(
WriteResult::Blocked {
decided_by: decision.decided_by,
},
None,
);
}
let api = DocsApi::new(docs);
let document = match api
.get_document(&opts.document_id, SuggestionsViewMode::default())
.await
{
Ok(document) => document,
Err(err) => {
return gated(
WriteResult::Failed {
detail: err.to_string(),
},
None,
)
}
};
let Some(revision_id) = document.revision_id.clone() else {
return gated(WriteResult::RefusedNoRevisionId, None);
};
let preview = match &opts.payload {
WritePayload::Replace {
search, match_case, ..
} => {
let corpus = document_text(&document);
WriteResult::WouldReplace {
occurrences: count_occurrences(&corpus, search, *match_case),
}
}
WritePayload::Append { text } => WriteResult::WouldAppend {
document_end_index: body_end_index(&document),
chars: text.chars().count(),
bytes: text.len(),
},
};
if opts.dry_run {
return gated(preview, Some(revision_id));
}
let leased = LeasedWrite {
log_prefix: "drive docs write",
operation: opts.payload.verb().log_operation(),
ledger_path: &opts.ledger_path,
file_id: &opts.document_id,
};
let lease_grant = match gate_optional_leased_write(
leased,
&files_api,
requires_lease,
opts.lease_token.as_deref(),
)
.await
{
Ok(grant) => grant,
Err(err) => return gated(err.into_result(), Some(revision_id)),
};
let result = match conclude_native_leased_write(
leased,
&lease_grant,
&files_api,
api.batch_update(&opts.document_id, opts.payload.to_request(), &revision_id)
.await,
ToString::to_string,
)
.await
{
Ok(response) => match &opts.payload {
WritePayload::Replace { .. } => WriteResult::Replaced {
occurrences_changed: response.occurrences_changed_for_replace(),
},
WritePayload::Append { text } => WriteResult::Appended {
chars: text.chars().count(),
bytes: text.len(),
},
},
Err(err) => {
let detail = err.to_string();
if is_stale_revision(&err) {
WriteResult::StaleRevision {
required_revision_id: revision_id.clone(),
detail,
}
} else {
WriteResult::Failed { detail }
}
}
};
drop(lease_grant);
gated(result, Some(revision_id))
}
fn document_text(document: &crate::drive::docs::types::Document) -> String {
document
.resolved_tabs()
.iter()
.filter_map(|tab| tab.body)
.flat_map(crate::drive::docs::structure::flatten)
.map(|element| element.text)
.collect::<Vec<_>>()
.join("\n")
}
fn body_end_index(document: &crate::drive::docs::types::Document) -> Option<i64> {
document
.resolved_tabs()
.first()
.and_then(|tab| tab.body)
.and_then(|body| body.content.last())
.map(crate::drive::docs::types::StructuralElement::end_index)
}
fn record_attempt(outcome: &WriteOutcome, opts: &WriteOptions, duration: Duration) {
let error = match &outcome.result {
WriteResult::Failed { detail } | WriteResult::StaleRevision { detail, .. } => {
Some(detail.clone())
}
_ => None,
};
let decided_by = match &outcome.result {
WriteResult::Blocked { decided_by } => decided_by.as_ref(),
_ => None,
};
let decided_by = write_gate::decided_by_log_fields(decided_by);
let occurrences_changed = match &outcome.result {
WriteResult::Replaced {
occurrences_changed,
} => Some(*occurrences_changed),
_ => None,
};
let inserted_chars = match &outcome.result {
WriteResult::Appended { chars, .. } => Some(*chars as i64),
_ => None,
};
request_log::record_drive_mutation(DriveMutationOutcome {
operation: opts.payload.verb().log_operation(),
file_id: outcome.document_id.clone(),
file_name: outcome.file_name.clone().unwrap_or_default(),
status: outcome.result.log_status().to_string(),
resolved_folder_id: outcome.resolved_folder_id.clone(),
decided_by_folder_id: decided_by.folder_id,
decided_by_depth: decided_by.depth,
decided_by_file_id: decided_by.file_id,
occurrences_changed,
inserted_chars,
required_revision_id: outcome.required_revision_id.clone(),
error,
duration,
..Default::default()
});
}
#[must_use]
pub fn describe(outcome: &WriteOutcome, verb: WriteVerb) -> String {
let name = outcome.file_name.as_deref().unwrap_or(&outcome.document_id);
match &outcome.result {
WriteResult::WouldReplace { occurrences } => format!(
"Would replace: {occurrences} occurrence(s) in '{name}' \
(counted from the copy just read)"
),
WriteResult::WouldAppend {
document_end_index,
chars,
bytes,
} => {
let where_ = document_end_index
.map(|index| format!(" (body ends at index {index})"))
.unwrap_or_default();
format!("Would append: {chars} char(s) / {bytes} byte(s) to '{name}'{where_}")
}
WriteResult::RefusedNotADocument { mime_type } => format!(
"Refused: '{name}' is not a Google Doc (mimeType: {mime_type}); \
`drive docs {}` only works on Google Docs",
verb.label()
),
WriteResult::RefusedShortcut => format!(
"Refused: '{name}' is a shortcut; `drive docs {}` doesn't follow shortcuts — \
resolve the target document's id and use that instead",
verb.label()
),
WriteResult::RefusedNoVisibleParents => format!(
"Refused: '{name}' has no parent folder visible to this account, so no folder \
rule can apply to it. This is normal for a Doc shared by link or email. \
Grant it by id instead: add {{\"file_id\": \"<document id>\", \"allow\": \
[\"docs-write\"]}} to write_permissions.rules. (Adding it to a folder in your \
own Drive and granting that folder `docs-write` also works.)"
),
WriteResult::RefusedNoRevisionId => format!(
"Refused: '{name}' returned no revision id, which Google sends only to callers \
with edit access — so this write cannot be leased against a known version. \
Request edit access, or check the account in use."
),
WriteResult::Blocked { decided_by } => match decided_by {
Some(rule) => format!(
"Blocked: '{name}' — refused by rule on {} {}{}",
rule.kind_label(),
rule.id(),
rule.depth_suffix()
),
None => format!("Blocked: '{name}' — refused by default policy (no matching rule)"),
},
WriteResult::RefusedNoLease => LeaseGateRefusal::NoLease
.describe_line(&outcome.document_id, &format!("'{name}'"))
.unwrap_or_default(),
WriteResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
.describe_line(&outcome.document_id, &format!("'{name}'"))
.unwrap_or_default(),
WriteResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
.describe_line(&outcome.document_id, &format!("'{name}'"))
.unwrap_or_default(),
WriteResult::RefusedLeaseStale => LeaseGateRefusal::Stale
.describe_line(&outcome.document_id, &format!("'{name}'"))
.unwrap_or_default(),
WriteResult::Replaced {
occurrences_changed,
} => format!("Replaced: {occurrences_changed} occurrence(s) in '{name}'"),
WriteResult::Appended { chars, bytes } => {
format!("Appended: {chars} char(s) / {bytes} byte(s) to '{name}'")
}
WriteResult::StaleRevision {
required_revision_id,
..
} => format!(
"Refused: '{name}' changed since it was read (revision lease \
{required_revision_id} no longer current) — nothing was written. \
Re-run to apply against the current version."
),
WriteResult::Failed { detail } => format!("Failed: '{name}': {detail}"),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::collections::HashSet;
use crate::drive::test_support::seed_lease;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::docs::client::DOCS_API_URL;
use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
async fn clients(server: &MockServer) -> (DriveClient, DocsClient) {
Mock::given(method("POST"))
.and(path("/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token", "expires_in": 3600,
})))
.mount(server)
.await;
let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut drive,
&test_credentials(),
&format!("{}/token", server.uri()),
);
let env = MapEnv::new().with(DOCS_API_URL, &server.uri());
let docs = DocsClient::from_drive_client_with(&env, &drive).unwrap();
(drive, docs)
}
fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> Mock {
let parents: Vec<&str> = parents.to_vec();
Mock::given(method("GET"))
.and(path(format!("/drive/v3/files/{id}")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id, "name": id, "mimeType": mime_type, "parents": parents,
"version": "1",
})))
}
fn leased_opts_for(document_id: &str) -> (Option<String>, std::path::PathBuf) {
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, document_id, "1");
(Some(token), ledger_path)
}
fn mount_folder(id: &str) -> Mock {
mount_file(id, "application/vnd.google-apps.folder", &[])
}
fn mount_document(revision: Option<&str>, text: &str) -> Mock {
let mut body = serde_json::json!({
"documentId": "doc-1",
"body": {"content": [{
"startIndex": 1, "endIndex": 1 + text.encode_utf16().count() as i64,
"paragraph": {"elements": [{"textRun": {"content": format!("{text}\n")}}]},
}]},
});
if let Some(revision) = revision {
body["revisionId"] = serde_json::json!(revision);
}
Mock::given(method("GET"))
.and(path("/v1/documents/doc-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
}
fn mount_batch_update(response: serde_json::Value) -> Mock {
Mock::given(method("POST"))
.and(path("/v1/documents/doc-1:batchUpdate"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
}
fn allow_rule(folder: &str) -> FolderPermissionRule {
FolderPermissionRule {
folder_id: Some(folder.to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::DocsWrite).collect(),
deny: HashSet::default(),
require_lease: true,
}
}
fn replace_opts(dry_run: bool) -> WriteOptions {
let (lease_token, ledger_path) = leased_opts_for("doc-1");
WriteOptions {
document_id: "doc-1".to_string(),
payload: WritePayload::Replace {
search: "Q3".to_string(),
replace: "Q4".to_string(),
match_case: true,
},
dry_run,
lease_token,
ledger_path,
}
}
fn append_opts(dry_run: bool) -> WriteOptions {
let (lease_token, ledger_path) = leased_opts_for("doc-1");
WriteOptions {
document_id: "doc-1".to_string(),
payload: WritePayload::Append {
text: "hello".to_string(),
},
dry_run,
lease_token,
ledger_path,
}
}
#[test]
fn count_occurrences_counts_non_overlapping_matches() {
assert_eq!(count_occurrences("Q3 and Q3 and Q3", "Q3", true), 3);
assert_eq!(count_occurrences("aaaa", "aa", true), 2);
}
#[test]
fn count_occurrences_honours_case_sensitivity_both_ways() {
assert_eq!(count_occurrences("it It IT", "it", true), 1);
assert_eq!(count_occurrences("it It IT", "it", false), 3);
}
#[test]
fn count_occurrences_of_an_empty_needle_is_zero() {
assert_eq!(count_occurrences("anything", "", true), 0);
assert_eq!(count_occurrences("anything", "", false), 0);
}
#[test]
fn count_occurrences_handles_multibyte_text() {
assert_eq!(count_occurrences("😀 x 😀", "😀", true), 2);
assert_eq!(count_occurrences("ÉCOLE école", "école", false), 2);
}
#[tokio::test]
async fn a_non_document_is_refused_before_the_gate_or_any_docs_call() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_SHEET_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
let outcome = write(
&drive,
&docs,
&replace_opts(false),
&[allow_rule("folder-1")],
)
.await;
assert!(matches!(
outcome.result,
WriteResult::RefusedNotADocument { .. }
));
}
#[tokio::test]
async fn a_shortcut_gets_its_own_refusal_not_the_generic_one() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_SHORTCUT_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
let outcome = write(
&drive,
&docs,
&replace_opts(false),
&[allow_rule("folder-1")],
)
.await;
assert_eq!(outcome.result, WriteResult::RefusedShortcut);
let text = describe(&outcome, WriteVerb::Replace);
assert!(text.contains("is a shortcut"), "{text}");
assert!(!text.contains("not a Google Doc"), "{text}");
}
#[tokio::test]
async fn an_empty_search_is_refused_before_any_network_call() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
let opts = WriteOptions {
payload: WritePayload::Replace {
search: String::new(),
replace: "x".to_string(),
match_case: true,
},
..replace_opts(false)
};
let outcome = write(&drive, &docs, &opts, &[]).await;
match outcome.result {
WriteResult::Failed { detail } => assert!(detail.contains("--search"), "{detail}"),
other => panic!("expected Failed, got {other:?}"),
}
}
#[tokio::test]
async fn a_denied_target_is_blocked_with_zero_docs_calls() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
let outcome = write(&drive, &docs, &replace_opts(false), &[]).await;
assert!(matches!(outcome.result, WriteResult::Blocked { .. }));
}
#[tokio::test]
async fn a_parentless_document_is_refused_distinctly_not_blocked() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &[])
.mount(&server)
.await;
let outcome = write(&drive, &docs, &replace_opts(false), &[]).await;
assert_eq!(outcome.result, WriteResult::RefusedNoVisibleParents);
let text = describe(&outcome, WriteVerb::Replace);
assert!(text.contains("file_id"), "names the actual fix: {text}");
}
#[tokio::test]
async fn a_file_rule_grants_a_parentless_document() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &[])
.mount(&server)
.await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
let rules = [FolderPermissionRule {
folder_id: None,
file_id: Some("doc-1".to_string()),
recursive: false,
allow: std::iter::once(DriveOperation::DocsWrite).collect(),
deny: HashSet::default(),
require_lease: true,
}];
let outcome = write(&drive, &docs, &replace_opts(true), &rules).await;
assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
}
#[tokio::test]
async fn an_ancestor_fetch_failure_produces_failed_not_allow() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/drive/v3/files/folder-1"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let outcome = write(
&drive,
&docs,
&replace_opts(false),
&[allow_rule("folder-1")],
)
.await;
assert!(
matches!(outcome.result, WriteResult::Failed { .. }),
"a chain that could not be resolved must never become an allow: {:?}",
outcome.result
);
}
#[tokio::test]
async fn the_batch_update_presents_the_revision_from_the_get_it_just_made() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-abc"), "Q3 report")
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/documents/doc-1:batchUpdate"))
.and(wiremock::matchers::body_partial_json(serde_json::json!({
"writeControl": {"requiredRevisionId": "rev-abc"},
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"documentId": "doc-1",
"replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
})))
.expect(1)
.mount(&server)
.await;
let outcome = write(
&drive,
&docs,
&replace_opts(false),
&[allow_rule("folder-1")],
)
.await;
assert_eq!(
outcome.result,
WriteResult::Replaced {
occurrences_changed: 1
}
);
assert_eq!(outcome.required_revision_id.as_deref(), Some("rev-abc"));
}
#[tokio::test]
async fn a_document_with_no_revision_id_is_refused_rather_than_written_unleased() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(None, "Q3 report").mount(&server).await;
let outcome = write(
&drive,
&docs,
&replace_opts(false),
&[allow_rule("folder-1")],
)
.await;
assert_eq!(outcome.result, WriteResult::RefusedNoRevisionId);
let text = describe(&outcome, WriteVerb::Replace);
assert!(text.contains("edit access"), "{text}");
}
#[tokio::test]
async fn a_stale_revision_400_is_its_own_outcome_not_a_generic_failure() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-old"), "Q3 report")
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/documents/doc-1:batchUpdate"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {
"code": 400,
"message": "The required revision ID 'rev-old' does not match the latest revision.",
"status": "INVALID_ARGUMENT",
},
})))
.mount(&server)
.await;
let outcome = write(
&drive,
&docs,
&replace_opts(false),
&[allow_rule("folder-1")],
)
.await;
match &outcome.result {
WriteResult::StaleRevision {
required_revision_id,
..
} => assert_eq!(required_revision_id, "rev-old"),
other => panic!("expected StaleRevision, got {other:?}"),
}
let text = describe(&outcome, WriteVerb::Replace);
assert!(text.contains("nothing was written"), "{text}");
assert!(text.contains("Re-run"), "{text}");
}
#[tokio::test]
async fn an_unrelated_400_stays_a_generic_failure() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/documents/doc-1:batchUpdate"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {"code": 400, "message": "Invalid requests[0]", "status": "INVALID_ARGUMENT"},
})))
.mount(&server)
.await;
let outcome = write(
&drive,
&docs,
&replace_opts(false),
&[allow_rule("folder-1")],
)
.await;
assert!(matches!(outcome.result, WriteResult::Failed { .. }));
}
#[tokio::test]
async fn dry_run_never_calls_batch_update() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
let outcome = write(
&drive,
&docs,
&replace_opts(true),
&[allow_rule("folder-1")],
)
.await;
assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
}
#[tokio::test]
async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.expect(2)
.mount(&server)
.await;
mount_folder("folder-1").expect(2).mount(&server).await;
let dry = write(&drive, &docs, &replace_opts(true), &[]).await;
let real = write(&drive, &docs, &replace_opts(false), &[]).await;
assert_eq!(dry.result, real.result);
assert!(matches!(dry.result, WriteResult::Blocked { .. }));
}
#[tokio::test]
async fn dry_run_replace_reports_the_occurrence_count_from_the_snapshot() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 then Q3 then Q3")
.mount(&server)
.await;
let outcome = write(
&drive,
&docs,
&replace_opts(true),
&[allow_rule("folder-1")],
)
.await;
assert_eq!(outcome.result, WriteResult::WouldReplace { occurrences: 3 });
let text = describe(&outcome, WriteVerb::Replace);
assert!(text.contains("3 occurrence(s)"), "{text}");
}
#[tokio::test]
async fn dry_run_append_reports_the_end_index_and_the_insert_size() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "body").mount(&server).await;
let outcome = write(&drive, &docs, &append_opts(true), &[allow_rule("folder-1")]).await;
match outcome.result {
WriteResult::WouldAppend {
document_end_index,
chars,
bytes,
} => {
assert_eq!(chars, 5);
assert_eq!(bytes, 5);
assert!(document_end_index.is_some());
}
other => panic!("expected WouldAppend, got {other:?}"),
}
}
#[tokio::test]
async fn a_zero_occurrence_replace_still_sends_the_batch_update() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "nothing matching here")
.mount(&server)
.await;
mount_batch_update(serde_json::json!({
"documentId": "doc-1",
"replies": [{"replaceAllText": {"occurrencesChanged": 0}}],
}))
.expect(1)
.mount(&server)
.await;
let outcome = write(
&drive,
&docs,
&replace_opts(false),
&[allow_rule("folder-1")],
)
.await;
assert_eq!(
outcome.result,
WriteResult::Replaced {
occurrences_changed: 0
}
);
}
#[tokio::test]
async fn an_append_reports_its_own_size_since_the_api_reports_no_count() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "body").mount(&server).await;
mount_batch_update(serde_json::json!({"documentId": "doc-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = write(
&drive,
&docs,
&append_opts(false),
&[allow_rule("folder-1")],
)
.await;
assert_eq!(outcome.result, WriteResult::Appended { chars: 5, bytes: 5 });
}
#[tokio::test]
async fn refuses_without_a_lease_when_the_rule_requires_one() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
let opts = WriteOptions {
lease_token: None,
ledger_path: std::path::PathBuf::new(),
..replace_opts(false)
};
let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
assert!(matches!(outcome.result, WriteResult::RefusedNoLease));
assert_eq!(outcome.result.log_status(), "refused-no-lease");
}
#[tokio::test]
async fn reports_a_lock_acquisition_failure_as_failed() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
let opts = replace_opts(false);
let mut lock_path = opts.ledger_path.clone().into_os_string();
lock_path.push(".lock");
std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
assert!(matches!(outcome.result, WriteResult::Failed { .. }));
}
#[tokio::test]
async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/drive/v3/files/doc-1"))
.respond_with(ResponseTemplate::new(500))
.with_priority(2)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/documents/doc-1:batchUpdate"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let outcome = write(
&drive,
&docs,
&replace_opts(false),
&[allow_rule("folder-1")],
)
.await;
assert!(matches!(outcome.result, WriteResult::Failed { .. }));
}
#[tokio::test]
async fn refuses_an_unknown_lease_token() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let opts = WriteOptions {
lease_token: Some("bogus-token".to_string()),
ledger_path,
..replace_opts(false)
};
let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
assert!(matches!(outcome.result, WriteResult::RefusedLeaseExpired));
}
#[tokio::test]
async fn refuses_a_lease_bound_to_a_different_file() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "some-other-doc", "1");
let opts = WriteOptions {
lease_token: Some(token),
ledger_path,
..replace_opts(false)
};
let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
assert!(matches!(outcome.result, WriteResult::RefusedLeaseWrongFile));
}
#[tokio::test]
async fn refuses_a_stale_lease_when_the_file_has_moved() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "doc-1", "0");
let opts = WriteOptions {
lease_token: Some(token),
ledger_path,
..replace_opts(false)
};
let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
assert!(matches!(outcome.result, WriteResult::RefusedLeaseStale));
}
#[tokio::test]
async fn require_lease_false_writes_without_a_lease() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
mount_batch_update(serde_json::json!({
"documentId": "doc-1",
"replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
}))
.mount(&server)
.await;
let rule = FolderPermissionRule {
folder_id: Some("folder-1".to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::DocsWrite).collect(),
deny: HashSet::default(),
require_lease: false,
};
let opts = WriteOptions {
lease_token: None,
ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
..replace_opts(false)
};
let outcome = write(&drive, &docs, &opts, &[rule]).await;
assert!(matches!(outcome.result, WriteResult::Replaced { .. }));
}
#[tokio::test]
async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
let rule = FolderPermissionRule {
folder_id: Some("folder-1".to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::DocsWrite).collect(),
deny: HashSet::default(),
require_lease: false,
};
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "doc-1", "0");
let opts = WriteOptions {
lease_token: Some(token),
ledger_path,
..replace_opts(false)
};
let outcome = write(&drive, &docs, &opts, &[rule]).await;
assert!(matches!(outcome.result, WriteResult::RefusedLeaseStale));
}
#[test]
fn describe_renders_every_lease_refusal_with_the_lease_acquire_hint() {
let outcome_with = |result: WriteResult| WriteOutcome {
document_id: "doc-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
required_revision_id: None,
result,
};
let text = describe(
&outcome_with(WriteResult::RefusedNoLease),
WriteVerb::Replace,
);
assert!(text.contains("requires a Drive write lease"), "{text}");
assert!(text.contains("drive lease acquire doc-1"), "{text}");
let text = describe(
&outcome_with(WriteResult::RefusedLeaseExpired),
WriteVerb::Replace,
);
assert!(text.contains("expired, released, or unknown"), "{text}");
assert!(text.contains("drive lease acquire doc-1"), "{text}");
let text = describe(
&outcome_with(WriteResult::RefusedLeaseWrongFile),
WriteVerb::Replace,
);
assert!(text.contains("acquired for a different file"), "{text}");
assert!(text.contains("drive lease acquire doc-1"), "{text}");
let text = describe(
&outcome_with(WriteResult::RefusedLeaseStale),
WriteVerb::Replace,
);
assert!(
text.contains("changed since the lease was acquired"),
"{text}"
);
assert!(text.contains("drive lease acquire doc-1"), "{text}");
}
#[tokio::test]
async fn a_dry_run_never_needs_a_lease() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-1"), "Q3 report")
.mount(&server)
.await;
let opts = WriteOptions {
lease_token: None,
ledger_path: std::path::PathBuf::new(),
..replace_opts(true)
};
let outcome = write(&drive, &docs, &opts, &[allow_rule("folder-1")]).await;
assert!(matches!(outcome.result, WriteResult::WouldReplace { .. }));
}
#[tokio::test]
async fn a_leased_docs_write_concludes_its_audit_pair_with_allowed() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-abc"), "Q3 report")
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/documents/doc-1:batchUpdate"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"documentId": "doc-1",
"replies": [{"replaceAllText": {"occurrencesChanged": 1}}],
})))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let outcome = write(
&drive,
&docs,
&replace_opts(false),
&[allow_rule("folder-1")],
)
.await;
assert!(matches!(outcome.result, WriteResult::Replaced { .. }));
let records = audit.records();
assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
assert_eq!(records[0].command, ["drive", "docs-replace"]);
}
#[tokio::test]
async fn a_leased_docs_write_that_fails_concludes_its_audit_pair_with_the_error() {
let server = MockServer::start().await;
let (drive, docs) = clients(&server).await;
mount_file("doc-1", GOOGLE_DOC_MIME_TYPE, &["folder-1"])
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_document(Some("rev-abc"), "Q3 report")
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/v1/documents/doc-1:batchUpdate"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let outcome = write(
&drive,
&docs,
&replace_opts(false),
&[allow_rule("folder-1")],
)
.await;
let WriteResult::Failed { detail } = &outcome.result else {
panic!("expected Failed, got {:?}", outcome.result);
};
let records = audit.records();
assert_eq!(audit.verdicts(), ["pending", "failed"], "{records:?}");
assert_eq!(records[1].error.as_deref(), Some(detail.as_str()));
}
}