joy_core/auth/
consumed.rs1use std::path::PathBuf;
15
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18
19use super::session::dirs_state_dir;
20use crate::error::JoyError;
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct ConsumedEntry {
25 pub token_id: String,
26 pub redeemed_at: DateTime<Utc>,
27 pub expires_at: Option<DateTime<Utc>>,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36struct ConsumedFile {
37 #[serde(default)]
38 entries: Vec<ConsumedEntry>,
39}
40
41fn consumed_path() -> Result<PathBuf, JoyError> {
42 Ok(dirs_state_dir()?.join("joy").join("consumed-tokens.json"))
43}
44
45fn load() -> Result<ConsumedFile, JoyError> {
46 let path = consumed_path()?;
47 if !path.exists() {
48 return Ok(ConsumedFile::default());
49 }
50 let data = std::fs::read_to_string(&path).map_err(|e| JoyError::ReadFile {
51 path: path.clone(),
52 source: e,
53 })?;
54 Ok(serde_json::from_str(&data).unwrap_or_default())
57}
58
59fn save(file: &ConsumedFile) -> Result<(), JoyError> {
60 let path = consumed_path()?;
61 if let Some(parent) = path.parent() {
62 std::fs::create_dir_all(parent).map_err(|e| JoyError::CreateDir {
63 path: parent.to_path_buf(),
64 source: e,
65 })?;
66 }
67 let data = serde_json::to_string_pretty(file).expect("consumed tokens serialize");
68 std::fs::write(&path, data).map_err(|e| JoyError::WriteFile {
69 path: path.clone(),
70 source: e,
71 })?;
72 #[cfg(unix)]
73 {
74 use std::os::unix::fs::PermissionsExt;
75 let perms = std::fs::Permissions::from_mode(0o600);
76 let _ = std::fs::set_permissions(&path, perms);
77 }
78 Ok(())
79}
80
81fn gc(file: &mut ConsumedFile) {
82 let now = Utc::now();
83 file.entries
84 .retain(|e| e.expires_at.map(|exp| exp > now).unwrap_or(true));
85}
86
87pub fn is_consumed(token_id: &str) -> Result<Option<DateTime<Utc>>, JoyError> {
89 let file = load()?;
90 Ok(file
91 .entries
92 .iter()
93 .find(|e| e.token_id == token_id)
94 .map(|e| e.redeemed_at))
95}
96
97pub fn mark_consumed(token_id: &str, expires_at: Option<DateTime<Utc>>) -> Result<(), JoyError> {
100 let mut file = load()?;
101 gc(&mut file);
102 if !file.entries.iter().any(|e| e.token_id == token_id) {
103 file.entries.push(ConsumedEntry {
104 token_id: token_id.to_string(),
105 redeemed_at: Utc::now(),
106 expires_at,
107 });
108 }
109 save(&file)
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use chrono::Duration;
116 use tempfile::tempdir;
117
118 fn with_state_dir<F: FnOnce()>(f: F) {
119 let _guard = super::super::STATE_ENV_LOCK
120 .lock()
121 .unwrap_or_else(|e| e.into_inner());
122 let dir = tempdir().unwrap();
123 unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
125 f();
126 unsafe { std::env::remove_var("XDG_STATE_HOME") };
128 }
129
130 #[test]
131 fn fresh_token_is_not_consumed() {
132 with_state_dir(|| {
133 assert!(is_consumed("unseen").unwrap().is_none());
134 });
135 }
136
137 #[test]
138 fn marking_consumed_is_observable() {
139 with_state_dir(|| {
140 mark_consumed("abc", Some(Utc::now() + Duration::hours(2))).unwrap();
141 assert!(is_consumed("abc").unwrap().is_some());
142 });
143 }
144
145 #[test]
146 fn remarking_same_id_is_idempotent() {
147 with_state_dir(|| {
148 let exp = Utc::now() + Duration::hours(2);
149 mark_consumed("abc", Some(exp)).unwrap();
150 mark_consumed("abc", Some(exp)).unwrap();
151 let file = load().unwrap();
152 let matches: Vec<&ConsumedEntry> = file
153 .entries
154 .iter()
155 .filter(|e| e.token_id == "abc")
156 .collect();
157 assert_eq!(matches.len(), 1);
158 });
159 }
160
161 #[test]
162 fn gc_drops_expired_entries_on_write() {
163 with_state_dir(|| {
164 let past = Utc::now() - Duration::hours(3);
165 let future = Utc::now() + Duration::hours(1);
166 let mut file = ConsumedFile::default();
169 file.entries.push(ConsumedEntry {
170 token_id: "old".into(),
171 redeemed_at: past - Duration::hours(1),
172 expires_at: Some(past),
173 });
174 file.entries.push(ConsumedEntry {
175 token_id: "new".into(),
176 redeemed_at: Utc::now(),
177 expires_at: Some(future),
178 });
179 save(&file).unwrap();
180
181 mark_consumed("fresh", Some(future)).unwrap();
183 let file = load().unwrap();
184 let ids: Vec<&str> = file.entries.iter().map(|e| e.token_id.as_str()).collect();
185 assert!(!ids.contains(&"old"), "expired entry should be GC'd");
186 assert!(ids.contains(&"new"));
187 assert!(ids.contains(&"fresh"));
188 });
189 }
190
191 #[test]
192 fn entry_without_expiry_is_kept() {
193 with_state_dir(|| {
194 mark_consumed("no-expiry", None).unwrap();
195 mark_consumed("other", Some(Utc::now() + Duration::hours(1))).unwrap();
196 assert!(is_consumed("no-expiry").unwrap().is_some());
197 });
198 }
199}