1use 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#[derive(Debug)]
15pub struct PreparedUserFile {
16 pub(crate) original: UserFile,
17 pub(crate) hashes: FileHashes,
18}
19
20impl PreparedUserFile {
21 pub fn size(&self) -> u64 {
24 self.original.size
25 }
26
27 pub fn modified_at(&self) -> SystemTime {
31 self.original.modified_at
32 }
33}
34
35#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct UserFile {
38 pub path: PathBuf,
40 pub size: u64,
42 pub modified_at: SystemTime,
44}
45
46pub 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
83pub 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
117pub(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
145pub(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
155pub(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
175pub(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
187pub(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#[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
252pub(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;