1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use time::OffsetDateTime;
7use time::format_description::well_known::Rfc3339;
8use tokio::fs;
9use tokio::io::AsyncWriteExt;
10
11use crate::error::Error;
12use crate::namespace::Namespace;
13use crate::storage::s3::S3Store;
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Lock {
17 pub id: String,
18 pub path: String,
19 pub locked_at: String,
20 pub owner: Owner,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Owner {
25 pub name: String,
26}
27
28pub struct LockStore {
35 backend: Backend,
36 max_age: Option<Duration>,
37}
38
39enum Backend {
40 Local { root: PathBuf },
41 Bucket(Box<S3Store>),
42}
43
44impl LockStore {
45 pub fn local(root: impl Into<PathBuf>) -> Self {
46 Self::over(Backend::Local { root: root.into() })
47 }
48
49 pub fn bucket(bucket: S3Store) -> Self {
50 Self::over(Backend::Bucket(Box::new(bucket)))
51 }
52
53 fn over(backend: Backend) -> Self {
54 Self {
55 backend,
56 max_age: None,
57 }
58 }
59
60 pub fn with_max_age(mut self, max_age: Option<Duration>) -> Self {
61 self.max_age = max_age;
62 self
63 }
64
65 pub fn max_age(&self) -> Option<Duration> {
66 self.max_age
67 }
68
69 pub fn stale_for(&self, lock: &Lock) -> Option<Duration> {
72 stale_for(lock, self.max_age)
73 }
74
75 pub fn id_of(path: &str) -> String {
76 hex::encode(Sha256::digest(path.as_bytes()))[..32].to_owned()
77 }
78
79 fn prefix(ns: &Namespace) -> String {
82 format!(".locks/{}/{}/", ns.org(), ns.repo())
83 }
84
85 fn key_of(ns: &Namespace, id: &str) -> String {
86 format!("{}{id}.json", Self::prefix(ns))
87 }
88
89 pub async fn create(&self, ns: &Namespace, path: &str, owner: &str) -> Result<Lock, Error> {
90 if path.is_empty() {
91 return Err(Error::MalformedLockPath);
92 }
93
94 let lock = Lock {
95 id: Self::id_of(path),
96 path: path.to_owned(),
97 locked_at: OffsetDateTime::now_utc()
98 .format(&Rfc3339)
99 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()),
100 owner: Owner {
101 name: owner.to_owned(),
102 },
103 };
104 let encoded = serde_json::to_vec(&lock)?;
105
106 if self.take(ns, &lock, &encoded).await? {
107 return Ok(lock);
108 }
109
110 let Some(held) = self.get(ns, &lock.id).await? else {
115 return Err(Error::LockHeld(Box::new(lock)));
116 };
117
118 let Some(age) = self.stale_for(&held) else {
119 return Err(Error::LockHeld(Box::new(held)));
120 };
121
122 self.discard(ns, &held.id).await?;
127
128 if !self.take(ns, &lock, &encoded).await? {
129 return match self.get(ns, &lock.id).await? {
130 Some(other) => Err(Error::LockHeld(Box::new(other))),
131 None => Err(Error::LockHeld(Box::new(lock))),
132 };
133 }
134
135 tracing::info!(
136 path = lock.path,
137 previous_owner = held.owner.name,
138 untouched_for_seconds = age.as_secs(),
139 new_owner = lock.owner.name,
140 "a lock nobody had touched was taken over"
141 );
142
143 Ok(lock)
144 }
145
146 async fn take(&self, ns: &Namespace, lock: &Lock, encoded: &[u8]) -> Result<bool, Error> {
147 match &self.backend {
148 Backend::Local { root } => {
149 Self::write_new(&Self::path_in(root, ns, &lock.id), encoded).await
150 }
151 Backend::Bucket(bucket) => {
152 bucket
153 .put_if_absent(&Self::key_of(ns, &lock.id), encoded.to_vec())
154 .await
155 }
156 }
157 }
158
159 async fn discard(&self, ns: &Namespace, id: &str) -> Result<(), Error> {
162 match self.remove(ns, id).await {
163 Ok(()) | Err(Error::LockNotFound) => Ok(()),
164 Err(error) => Err(error),
165 }
166 }
167
168 async fn write_new(path: &Path, encoded: &[u8]) -> Result<bool, Error> {
169 let parent = path.parent().expect("lock paths have a parent");
170 fs::create_dir_all(parent).await?;
171
172 match fs::OpenOptions::new()
173 .write(true)
174 .create_new(true)
175 .open(path)
176 .await
177 {
178 Ok(mut file) => {
179 file.write_all(encoded).await?;
180 file.sync_all().await?;
181 Ok(true)
182 }
183 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
184 Err(error) => Err(error.into()),
185 }
186 }
187
188 pub async fn get(&self, ns: &Namespace, id: &str) -> Result<Option<Lock>, Error> {
189 if !is_well_formed_id(id) {
190 return Ok(None);
191 }
192
193 let encoded = match &self.backend {
194 Backend::Local { root } => match fs::read(Self::path_in(root, ns, id)).await {
195 Ok(bytes) => Some(bytes),
196 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
197 Err(error) => return Err(error.into()),
198 },
199 Backend::Bucket(bucket) => bucket.get_bytes(&Self::key_of(ns, id)).await?,
200 };
201
202 Ok(encoded.and_then(|bytes| serde_json::from_slice(&bytes).ok()))
203 }
204
205 pub async fn list(&self, ns: &Namespace) -> Result<Vec<Lock>, Error> {
206 let mut locks = match &self.backend {
207 Backend::Local { root } => Self::list_local(&Self::directory_in(root, ns)).await?,
208 Backend::Bucket(bucket) => Self::list_bucket(bucket, ns).await?,
209 };
210
211 locks.sort_by(|a: &Lock, b: &Lock| a.path.cmp(&b.path));
212 Ok(locks)
213 }
214
215 async fn list_local(directory: &Path) -> Result<Vec<Lock>, Error> {
216 let Ok(mut entries) = fs::read_dir(directory).await else {
217 return Ok(Vec::new());
218 };
219
220 let mut locks = Vec::new();
221 while let Some(entry) = entries.next_entry().await? {
222 if let Ok(bytes) = fs::read(entry.path()).await
223 && let Ok(lock) = serde_json::from_slice(&bytes)
224 {
225 locks.push(lock);
226 }
227 }
228
229 Ok(locks)
230 }
231
232 async fn list_bucket(bucket: &S3Store, ns: &Namespace) -> Result<Vec<Lock>, Error> {
237 let mut locks = Vec::new();
238
239 for key in bucket.keys(&Self::prefix(ns)).await? {
240 if let Some(bytes) = bucket.get_bytes(&key).await?
241 && let Ok(lock) = serde_json::from_slice(&bytes)
242 {
243 locks.push(lock);
244 }
245 }
246
247 Ok(locks)
248 }
249
250 pub async fn remove(&self, ns: &Namespace, id: &str) -> Result<(), Error> {
251 if !is_well_formed_id(id) {
252 return Err(Error::LockNotFound);
253 }
254
255 let removed = match &self.backend {
256 Backend::Local { root } => match fs::remove_file(Self::path_in(root, ns, id)).await {
257 Ok(()) => true,
258 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
259 Err(error) => return Err(error.into()),
260 },
261 Backend::Bucket(bucket) => bucket.delete(&Self::key_of(ns, id)).await?,
262 };
263
264 removed.then_some(()).ok_or(Error::LockNotFound)
265 }
266
267 fn directory_in(root: &Path, ns: &Namespace) -> PathBuf {
268 root.join(".locks").join(ns.org()).join(ns.repo())
269 }
270
271 fn path_in(root: &Path, ns: &Namespace, id: &str) -> PathBuf {
272 Self::directory_in(root, ns).join(format!("{id}.json"))
273 }
274}
275
276pub fn stale_for(lock: &Lock, max_age: Option<Duration>) -> Option<Duration> {
280 let max_age = max_age?;
281 let taken = OffsetDateTime::parse(&lock.locked_at, &Rfc3339).ok()?;
282
283 let age = Duration::try_from(OffsetDateTime::now_utc() - taken).ok()?;
285
286 (age > max_age).then_some(age)
287}
288
289fn is_well_formed_id(id: &str) -> bool {
290 id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit())
291}
292
293#[cfg(test)]
294mod tests;