squigit_storage/
version.rs1use std::fs::{self, File, OpenOptions};
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use chrono::{DateTime, Utc};
11use fs2::FileExt;
12use serde::{Deserialize, Serialize};
13
14use crate::error::{Result, StorageError};
15
16pub const VERSION_FILE_NAME: &str = "version.json";
17const VERSION_LOCK_FILE_NAME: &str = "version.lock";
18
19#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum VersionType {
22 Calver,
23 Semver,
24}
25
26#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
27pub struct ProductVersion {
28 pub current_version: Option<String>,
29 pub latest_version: String,
30 pub version_type: VersionType,
31 pub released_at: String,
32 pub content: String,
33}
34
35#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
36pub struct VersionFile {
37 pub app: ProductVersion,
38 pub cli: ProductVersion,
39 pub ocr: ProductVersion,
40 pub last_fetch_at: DateTime<Utc>,
41}
42
43#[derive(Clone, Debug)]
44pub struct VersionStore {
45 base_dir: PathBuf,
46 version_path: PathBuf,
47 lock_path: PathBuf,
48}
49
50impl VersionStore {
51 pub fn new() -> Result<Self> {
52 let base_dir = crate::paths::base_config_dir().ok_or(StorageError::NoConfigDir)?;
53 Self::with_base_dir(base_dir)
54 }
55
56 pub fn with_base_dir(base_dir: PathBuf) -> Result<Self> {
57 fs::create_dir_all(&base_dir)?;
58 Ok(Self {
59 version_path: base_dir.join(VERSION_FILE_NAME),
60 lock_path: base_dir.join(VERSION_LOCK_FILE_NAME),
61 base_dir,
62 })
63 }
64
65 pub fn load(&self) -> Result<Option<VersionFile>> {
66 load_version_file(&self.version_path)
67 }
68
69 pub fn lock(&self) -> Result<VersionStoreGuard> {
70 fs::create_dir_all(&self.base_dir)?;
71 let mut options = OpenOptions::new();
72 options.read(true).write(true).create(true);
73 #[cfg(unix)]
74 {
75 use std::os::unix::fs::OpenOptionsExt;
76 options.mode(0o600);
77 }
78 let lock_file = options.open(&self.lock_path)?;
79 lock_file.lock_exclusive()?;
80 Ok(VersionStoreGuard {
81 version_path: self.version_path.clone(),
82 lock_file,
83 })
84 }
85}
86
87pub struct VersionStoreGuard {
88 version_path: PathBuf,
89 lock_file: File,
90}
91
92impl VersionStoreGuard {
93 pub fn load(&self) -> Result<Option<VersionFile>> {
94 load_version_file(&self.version_path)
95 }
96
97 pub fn save(&self, value: &VersionFile) -> Result<()> {
98 let mut json = serde_json::to_vec(value)?;
99 json.push(b'\n');
100 atomic_write(&self.version_path, &json)
101 }
102}
103
104impl Drop for VersionStoreGuard {
105 fn drop(&mut self) {
106 let _ = FileExt::unlock(&self.lock_file);
107 }
108}
109
110fn load_version_file(path: &Path) -> Result<Option<VersionFile>> {
111 let contents = match fs::read(path) {
112 Ok(contents) => contents,
113 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
114 Err(error) => return Err(error.into()),
115 };
116 serde_json::from_slice(&contents)
117 .map(Some)
118 .map_err(Into::into)
119}
120
121fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
122 let parent = path.parent().ok_or_else(|| {
123 StorageError::Io(std::io::Error::new(
124 std::io::ErrorKind::InvalidInput,
125 format!("Path has no parent: {}", path.display()),
126 ))
127 })?;
128 fs::create_dir_all(parent)?;
129
130 if let Ok(metadata) = fs::symlink_metadata(path) {
131 if metadata.file_type().is_symlink() || !metadata.is_file() {
132 return Err(StorageError::Io(std::io::Error::new(
133 std::io::ErrorKind::InvalidInput,
134 format!("Refusing non-regular version file: {}", path.display()),
135 )));
136 }
137 }
138
139 let file_name = path
140 .file_name()
141 .and_then(|value| value.to_str())
142 .unwrap_or("version.json");
143 let temporary = path.with_file_name(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
144
145 let write_result = (|| -> Result<()> {
146 let mut options = OpenOptions::new();
147 options.write(true).create_new(true);
148 #[cfg(unix)]
149 {
150 use std::os::unix::fs::OpenOptionsExt;
151 options.mode(0o600);
152 }
153 let mut file = options.open(&temporary)?;
154 file.write_all(contents)?;
155 file.sync_all()?;
156 drop(file);
157
158 crate::secure_file::replace_file(&temporary, path)?;
159 crate::secure_file::sync_parent(parent)?;
160 Ok(())
161 })();
162
163 if write_result.is_err() {
164 let _ = fs::remove_file(&temporary);
165 }
166 write_result
167}