use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::cli::drive::format::JsonlSerialize;
use crate::drive::lease::ledger::{LeaseLedger, LedgerLock, ReleaseOutcome};
#[derive(Debug, Clone)]
pub struct ReleaseOptions {
pub token: String,
pub ledger_path: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum ReleaseResult {
Released {
token: String,
file_id: String,
expires_at: DateTime<Utc>,
},
NotLive {
token: String,
file_id: String,
expires_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
released_at: Option<DateTime<Utc>>,
},
NoSuchToken,
Failed {
detail: String,
},
}
impl JsonlSerialize for ReleaseResult {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<(), anyhow::Error> {
crate::cli::drive::format::write_scalar_jsonl(self, out)
}
}
impl ReleaseResult {
fn verdict(&self) -> &'static str {
match self {
Self::Released { .. } => "released",
Self::NotLive { .. } => "release-not-live",
Self::NoSuchToken => "release-no-such-token",
Self::Failed { .. } => "failed",
}
}
pub fn exit_code(&self) -> i32 {
match self {
Self::Released { .. } | Self::NotLive { .. } => 0,
Self::NoSuchToken | Self::Failed { .. } => 1,
}
}
}
pub async fn release(opts: &ReleaseOptions) -> ReleaseResult {
let result = release_inner(opts).await;
record_attempt(opts, &result);
result
}
async fn release_inner(opts: &ReleaseOptions) -> ReleaseResult {
let outcome = async {
let lock = LedgerLock::acquire_waiting(&opts.ledger_path).await?;
LeaseLedger::mutate(&lock, &opts.ledger_path, |ledger| {
ledger.release(&opts.token, Utc::now(), None)
})
}
.await;
match outcome {
Ok(ReleaseOutcome::Released {
file_id,
expires_at,
}) => ReleaseResult::Released {
token: opts.token.clone(),
file_id,
expires_at,
},
Ok(ReleaseOutcome::NotLive {
file_id,
expires_at,
released_at,
}) => ReleaseResult::NotLive {
token: opts.token.clone(),
file_id,
expires_at,
released_at,
},
Ok(ReleaseOutcome::NotFound) => ReleaseResult::NoSuchToken,
Err(err) => ReleaseResult::Failed {
detail: err.to_string(),
},
}
}
fn record_attempt(opts: &ReleaseOptions, result: &ReleaseResult) {
let (file_id, error) = match result {
ReleaseResult::Released { file_id, .. } | ReleaseResult::NotLive { file_id, .. } => {
(file_id.clone(), None)
}
ReleaseResult::NoSuchToken => (String::new(), None),
ReleaseResult::Failed { detail } => (
file_id_for_token(&opts.ledger_path, &opts.token).unwrap_or_default(),
Some(detail.clone()),
),
};
let outcome = crate::request_log::AuditOutcome {
command: vec!["drive".to_string(), "lease-release".to_string()],
integration: "drive",
file_id,
lease_id: Some(opts.token.clone()),
verdict: result.verdict().to_string(),
error,
..Default::default()
};
if let Err(err) = crate::request_log::record_audit_event(outcome) {
tracing::warn!("drive lease release: failed to write audit record: {err}");
}
}
fn file_id_for_token(ledger_path: &std::path::Path, token: &str) -> Option<String> {
LeaseLedger::load(ledger_path)
.ok()?
.get(token)
.map(|record| record.file_id.clone())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::lease::ledger::{LeaseBackup, LeaseRecord};
use crate::test_support::AuditLogGuard;
use chrono::Duration as ChronoDuration;
use std::path::Path;
fn backup() -> LeaseBackup {
LeaseBackup::Bytes {
path: std::path::PathBuf::from("/tmp/backup.bin"),
sha256: "deadbeef".to_string(),
size: 4,
}
}
fn seed(
ledger_path: &Path,
token: &str,
expires_at: DateTime<Utc>,
released_at: Option<DateTime<Utc>>,
) {
let mut ledger = LeaseLedger::load(ledger_path).unwrap_or_default();
ledger.insert(LeaseRecord {
token: token.to_string(),
file_id: "file-1".to_string(),
version: "1".to_string(),
modified_time: None,
backup: backup(),
acquired_at: Utc::now() - ChronoDuration::minutes(5),
expires_at,
released_at,
superseded_by: None,
restored_at: None,
restored_sheet_id: None,
});
ledger.save(ledger_path).unwrap();
}
fn opts(dir: &Path, token: &str) -> ReleaseOptions {
ReleaseOptions {
token: token.to_string(),
ledger_path: dir.join("lease-ledger.jsonl"),
}
}
#[tokio::test]
async fn a_live_lease_is_released_and_its_row_is_kept() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "live-token");
let expires_at = Utc::now() + ChronoDuration::minutes(30);
seed(&test_opts.ledger_path, "live-token", expires_at, None);
let result = release(&test_opts).await;
assert!(matches!(
&result,
ReleaseResult::Released { token, file_id, .. }
if token == "live-token" && file_id == "file-1"
));
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
let record = ledger.get("live-token").unwrap();
assert!(record.released_at.is_some());
assert!(!record.is_live(Utc::now()));
}
#[tokio::test]
async fn an_already_released_lease_keeps_its_original_timestamp() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "released-token");
let first_release = Utc::now() - ChronoDuration::minutes(1);
seed(
&test_opts.ledger_path,
"released-token",
Utc::now() + ChronoDuration::minutes(30),
Some(first_release),
);
let result = release(&test_opts).await;
assert!(matches!(
&result,
ReleaseResult::NotLive { released_at: Some(at), .. } if *at == first_release
));
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
assert_eq!(
ledger.get("released-token").unwrap().released_at,
Some(first_release),
"re-releasing must not overwrite the original release's timestamp"
);
}
#[tokio::test]
async fn an_expired_lease_is_reported_as_not_live_without_being_stamped() {
let dir = tempfile::tempdir().unwrap();
let audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "expired-token");
seed(
&test_opts.ledger_path,
"expired-token",
Utc::now() - ChronoDuration::minutes(1),
None,
);
let result = release(&test_opts).await;
assert!(matches!(
&result,
ReleaseResult::NotLive {
released_at: None,
..
}
));
let ledger = LeaseLedger::load(&test_opts.ledger_path).unwrap();
assert!(
ledger.get("expired-token").unwrap().released_at.is_none(),
"expiry already ended this lease; release must not claim credit for it"
);
let record = audit.records().pop().unwrap();
assert_eq!(
record.context.get("verdict").map(String::as_str),
Some("release-not-live")
);
assert_eq!(
record.context.get("file_id").map(String::as_str),
Some("file-1")
);
}
#[tokio::test]
async fn an_unknown_token_is_reported_rather_than_silently_succeeding() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let result = release(&opts(dir.path(), "no-such-token")).await;
assert!(matches!(result, ReleaseResult::NoSuchToken));
}
#[tokio::test]
async fn an_unreadable_ledger_is_reported_as_failed() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "any-token");
std::fs::write(&test_opts.ledger_path, "{not json}\n").unwrap();
let result = release(&test_opts).await;
assert!(matches!(result, ReleaseResult::Failed { .. }));
}
#[tokio::test]
async fn a_release_waits_for_a_concurrent_holder_then_releases() {
let dir = tempfile::tempdir().unwrap();
let _audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "live-token");
seed(
&test_opts.ledger_path,
"live-token",
Utc::now() + ChronoDuration::minutes(30),
None,
);
let held = LedgerLock::acquire(&test_opts.ledger_path).unwrap();
let releaser = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(100));
drop(held);
});
let result = release(&test_opts).await;
releaser.join().unwrap();
assert!(
matches!(result, ReleaseResult::Released { .. }),
"{result:?}"
);
}
#[test]
fn a_failed_release_audits_the_file_its_token_covers() {
let dir = tempfile::tempdir().unwrap();
let audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "live-token");
seed(
&test_opts.ledger_path,
"live-token",
Utc::now() + ChronoDuration::minutes(30),
None,
);
record_attempt(
&test_opts,
&ReleaseResult::Failed {
detail: "timed out".to_string(),
},
);
let records = audit.records();
assert_eq!(records.len(), 1, "{records:?}");
assert_eq!(
records[0].context.get("file_id").map(String::as_str),
Some("file-1"),
"{records:?}"
);
}
#[tokio::test]
async fn a_release_writes_an_audit_record_naming_the_token() {
let dir = tempfile::tempdir().unwrap();
let audit = AuditLogGuard::redirect(dir.path());
let test_opts = opts(dir.path(), "live-token");
seed(
&test_opts.ledger_path,
"live-token",
Utc::now() + ChronoDuration::minutes(30),
None,
);
let result = release(&test_opts).await;
assert!(matches!(result, ReleaseResult::Released { .. }));
let records = audit.records();
let record = records
.iter()
.find(|r| r.command == vec!["drive".to_string(), "lease-release".to_string()])
.expect("a release writes one audit record");
assert_eq!(
record.context.get("verdict").map(String::as_str),
Some("released")
);
assert_eq!(
record.context.get("lease_id").map(String::as_str),
Some("live-token")
);
}
#[test]
fn result_serializes_to_jsonl() {
let mut out = Vec::new();
ReleaseResult::NoSuchToken.write_jsonl(&mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert!(text.contains("\"status\":\"no-such-token\""), "{text}");
}
#[test]
fn exit_code_matches_the_documented_classification() {
let cases: Vec<(ReleaseResult, i32)> = vec![
(
ReleaseResult::Released {
token: "tok".to_string(),
file_id: "file-1".to_string(),
expires_at: Utc::now(),
},
0,
),
(
ReleaseResult::NotLive {
token: "tok".to_string(),
file_id: "file-1".to_string(),
expires_at: Utc::now(),
released_at: None,
},
0,
),
(ReleaseResult::NoSuchToken, 1),
(
ReleaseResult::Failed {
detail: "boom".to_string(),
},
1,
),
];
for (result, expected) in cases {
assert_eq!(result.exit_code(), expected, "{result:?}");
}
}
}