Skip to main content

squigit_storage/
version.rs

1// Copyright 2026 a7mddra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared persisted release metadata for Squigit shells.
5
6use 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}