Skip to main content

coven_database/
user_file.rs

1//! Preparing an original does no database work and never changes the file.
2
3use crate::file_hashes::{FileHasher, FileHashes};
4use crate::DbError;
5use coven_foundation::files::{ObservationError, UserOriginal};
6use std::{
7    path::{Path, PathBuf},
8    time::SystemTime,
9};
10
11/// An original read whole once before entering a write: its path, size and
12/// modification time, and the content hash and chunk hashes of the bytes
13/// that read found, which coven records and checks the original against.
14#[derive(Debug)]
15pub struct PreparedUserFile {
16    pub(crate) original: UserFile,
17    pub(crate) hashes: FileHashes,
18}
19
20impl PreparedUserFile {
21    /// Its size in bytes when it was read, which coven records and checks
22    /// the original against.
23    pub fn size(&self) -> u64 {
24        self.original.size
25    }
26
27    /// Its modification time when it was read: the exact value coven
28    /// records and checks the original against, at the precision the
29    /// operating system gives.
30    pub fn modified_at(&self) -> SystemTime {
31        self.original.modified_at
32    }
33}
34
35/// The facts recorded for the user's original, which coven never changes.
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct UserFile {
38    /// The user's original path.
39    pub path: PathBuf,
40    /// Its recorded size in bytes.
41    pub size: u64,
42    /// Its recorded modification time.
43    pub modified_at: SystemTime,
44}
45
46/// Read a user's original once, recording its whole-file hash and each upload
47/// chunk's hash from the same bytes, and reporting total bytes read. Changes
48/// during the read are refused; later ones fail its upload, whether it is
49/// staged for the cloud or moved there later.
50pub async fn prepare_user_file(
51    path: &Path,
52    progress: impl Fn(u64) + Send + Sync,
53) -> Result<PreparedUserFile, DbError> {
54    let mut hash = FileHasher::new();
55    let mut read = 0;
56    let observed = UserOriginal::at(path.to_owned())
57        .observe(|bytes| {
58            hash.update(bytes);
59            read += bytes.len() as u64;
60            progress(read);
61        })
62        .await?;
63    Ok(PreparedUserFile {
64        original: UserFile {
65            path: observed.path().to_owned(),
66            size: observed.size(),
67            modified_at: observed.modified_at(),
68        },
69        hashes: hash.finish(),
70    })
71}
72
73impl From<ObservationError> for DbError {
74    fn from(error: ObservationError) -> Self {
75        match error {
76            ObservationError::Missing(path) => Self::UserFileMissing { path },
77            ObservationError::Changed(path) => Self::UserFileChanged { path },
78            ObservationError::File(error) => error.into(),
79        }
80    }
81}
82
83/// A time as `coven_user_files` lists a modification time, in
84/// `modified_seconds` and `modified_nanos`: whole seconds since the Unix
85/// epoch, rounded down, and the nanoseconds past that second, from 0 through
86/// 999,999,999 (§17.3). Half a second before the epoch is (-1, 500,000,000).
87/// An app compares a file's time on disk with the view's through it.
88pub fn unix_seconds_and_nanos(time: SystemTime) -> (i64, u32) {
89    let (seconds, nanos) = match time.duration_since(std::time::UNIX_EPOCH) {
90        Ok(after) => (i128::from(after.as_secs()), after.subsec_nanos()),
91        Err(before) => {
92            let before = before.duration();
93            match before.subsec_nanos() {
94                0 => (-i128::from(before.as_secs()), 0),
95                nanos => (-i128::from(before.as_secs()) - 1, 1_000_000_000 - nanos),
96            }
97        }
98    };
99    (
100        i64::try_from(seconds)
101            .expect("every platform's time fits: Unix keeps i64 seconds, Windows's ends in 30828"),
102        nanos,
103    )
104}
105
106pub(crate) fn encode_time(time: SystemTime) -> Vec<u8> {
107    let (sign, elapsed) = match time.duration_since(std::time::UNIX_EPOCH) {
108        Ok(elapsed) => (0, elapsed),
109        Err(error) => (1, error.duration()),
110    };
111    let mut bytes = vec![sign];
112    bytes.extend(elapsed.as_secs().to_be_bytes());
113    bytes.extend(elapsed.subsec_nanos().to_be_bytes());
114    bytes
115}
116
117/// Record `original` as `file`'s original on this device, with the content
118/// hash and chunk hashes of the bytes its read found (§17.3).
119pub(crate) fn record(
120    db: &crate::sqlite::DatabaseConnection,
121    file: coven_foundation::id_source::FileId,
122    original: &UserFile,
123    hashes: &FileHashes,
124) -> Result<(), DbError> {
125    let size = crate::sqlite::integer("file size", original.size)?;
126    db.internal_execute(
127        "INSERT INTO _coven_user_files(file,path,size,modified_at,hash) VALUES(?1,?2,?3,?4,?5)",
128        rusqlite::params![
129            file,
130            coven_foundation::files::path_bytes(&original.path),
131            size,
132            encode_time(original.modified_at),
133            hashes.content.as_bytes().as_slice()
134        ],
135    )?;
136    for (index, hash) in hashes.chunks().iter().enumerate() {
137        db.internal_execute(
138            "INSERT INTO _coven_user_chunks(file,chunk,hash) VALUES(?1,?2,?3)",
139            rusqlite::params![file, index as i64, hash.as_bytes().as_slice()],
140        )?;
141    }
142    Ok(())
143}
144
145/// Forget `file`'s original here, and its chunk hashes with it: the file
146/// never touches it again (§17.3).
147pub(crate) fn forget(
148    db: &crate::sqlite::DatabaseConnection,
149    file: coven_foundation::id_source::FileId,
150) -> Result<(), DbError> {
151    db.internal_execute("DELETE FROM _coven_user_files WHERE file=?1", [file])?;
152    Ok(())
153}
154
155/// The new file `to` over `from`'s bytes shares `from`'s original here and
156/// its chunk hashes (§17.2).
157pub(crate) fn share(
158    db: &crate::sqlite::DatabaseConnection,
159    from: coven_foundation::id_source::FileId,
160    to: coven_foundation::id_source::FileId,
161) -> Result<(), DbError> {
162    db.internal_execute(
163        "INSERT INTO _coven_user_files(file,path,size,modified_at,hash)
164         SELECT ?2,path,size,modified_at,hash FROM _coven_user_files WHERE file=?1",
165        rusqlite::params![from, to],
166    )?;
167    db.internal_execute(
168        "INSERT INTO _coven_user_chunks(file,chunk,hash)
169         SELECT ?2,chunk,hash FROM _coven_user_chunks WHERE file=?1",
170        rusqlite::params![from, to],
171    )?;
172    Ok(())
173}
174
175/// The files the upload `upload` switches are stored: a stored file never
176/// moves again, so its original's chunk hashes go; the original stays
177/// recorded (§17.3).
178pub(crate) fn uploaded(db: &crate::sqlite::DatabaseConnection, upload: i64) -> Result<(), DbError> {
179    db.internal_execute(
180        "DELETE FROM _coven_user_chunks
181         WHERE file IN (SELECT file FROM _coven_upload_files WHERE upload=?1)",
182        [upload],
183    )?;
184    Ok(())
185}
186
187/// The content hash and chunk hashes recorded with `file`'s original here,
188/// if it has one. Its chunk hashes are kept only while the file is on this
189/// device only: they go once it is stored (§17.3).
190pub(crate) fn hashes(
191    db: &crate::sqlite::DatabaseConnection,
192    file: coven_foundation::id_source::FileId,
193) -> Result<Option<FileHashes>, DbError> {
194    let Some(content) = db
195        .query("SELECT hash FROM _coven_user_files WHERE file=?1", [file], |r| {
196            r.get::<_, Vec<u8>>(0)
197        })?
198        .pop()
199    else {
200        return Ok(None);
201    };
202    let chunks = db.query(
203        "SELECT hash FROM _coven_user_chunks WHERE file=?1 ORDER BY chunk",
204        [file],
205        |r| r.get::<_, Vec<u8>>(0),
206    )?;
207    Ok(Some(FileHashes::stored(content, chunks)?))
208}
209
210/// The user's original this device recorded for `file`, if it has one.
211#[expect(
212    clippy::map_err_ignore,
213    reason = "a stored value that won't convert or decode is DamagedDatabase"
214)]
215pub(crate) fn read(
216    db: &crate::sqlite::DatabaseConnection,
217    file: coven_foundation::id_source::FileId,
218) -> Result<Option<UserFile>, DbError> {
219    db.query("SELECT path,size,modified_at FROM _coven_user_files WHERE file=?1", [file], |row| {
220        Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, i64>(1)?, row.get::<_, Vec<u8>>(2)?))
221    })?
222    .into_iter()
223    .next()
224    .map(|(path, size, modified_at)| {
225        Ok(UserFile {
226            path: decode_path(path)?,
227            size: u64::try_from(size).map_err(|_| DbError::DamagedDatabase)?,
228            modified_at: decode_time(&modified_at)?,
229        })
230    })
231    .transpose()
232}
233
234pub(crate) fn decode_time(bytes: &[u8]) -> Result<SystemTime, DbError> {
235    if bytes.len() != 13 {
236        return Err(DbError::DamagedDatabase);
237    }
238    let seconds = u64::from_be_bytes(bytes[1..9].try_into().expect("checked time width"));
239    let nanos = u32::from_be_bytes(bytes[9..].try_into().expect("checked time width"));
240    if nanos >= 1_000_000_000 {
241        return Err(DbError::DamagedDatabase);
242    }
243    let elapsed = std::time::Duration::new(seconds, nanos);
244    match bytes[0] {
245        0 => std::time::UNIX_EPOCH.checked_add(elapsed),
246        1 => std::time::UNIX_EPOCH.checked_sub(elapsed),
247        _ => None,
248    }
249    .ok_or(DbError::DamagedDatabase)
250}
251
252/// A path `path_bytes` recorded; bytes it never gives are a damaged
253/// database.
254pub(crate) fn decode_path(bytes: Vec<u8>) -> Result<PathBuf, DbError> {
255    coven_foundation::files::path_from_bytes(bytes)
256        .map_err(|coven_foundation::files::PathBytesError| DbError::DamagedDatabase)
257}
258
259#[cfg(test)]
260#[path = "user_file_tests.rs"]
261pub(crate) mod tests;