fixed some bugs

This commit is contained in:
nvrl committed 2026-07-18 19:24:11 +02:00
1 parent 7766628098
commit a90cf83ff5
39 files changed
+1573 -1285

No files matched your search

+4
View File
@@ -927,6 +927,10 @@ pub enum ErrorKind {
/// The API was reached and refused, or the transfer broke. The one class
/// where an unchanged retry can legitimately succeed.
Remote,
/// The account is out of storage. Distinct from [`Denied`](Self::Denied)
/// because the user *can* fix it, and distinct from [`Remote`](Self::Remote)
/// because retrying an upload that did not fit will not make it fit.
Quota,
/// Something on this machine failed: the database, the content cache, the
/// filesystem. Not the user's doing and not theirs to fix.
#[default]
-1
View File
@@ -63,5 +63,4 @@ impl Db {
}
Ok(out)
}
}
-1
View File
@@ -122,5 +122,4 @@ impl Db {
// keyed by uid display string. `recursive` marks a folder pin: its whole
// subtree counts as pinned, resolved against `nodes` via a CTE so a new
// descendant is covered the moment it lands in the node cache.
}
-1
View File
@@ -44,5 +44,4 @@ impl Db {
)?;
Ok(())
}
}
-1
View File
@@ -160,4 +160,3 @@ pub(super) fn local_hit(row: &rusqlite::Row) -> rusqlite::Result<LocalFileHit> {
mtime: row.get(4)?,
})
}
-3
View File
@@ -5,7 +5,6 @@
//! is already on disk somewhere, so a change here would silently diverge from
//! what an existing install actually has. Add a new one instead.
use super::Db;
use crate::Result;
@@ -84,7 +83,6 @@ impl Db {
tx.commit()?;
Ok(())
}
}
/// Schema v1: nodes + FTS5 trigram index + cache LRU. `sync_state` is created
@@ -329,4 +327,3 @@ const MIGRATION_V13: &str = "
ALTER TABLE photos ADD COLUMN media_type TEXT;
ALTER TABLE photos ADD COLUMN kind INTEGER NOT NULL DEFAULT 0;
";
+2 -3
View File
@@ -40,9 +40,8 @@ pub use devices::StoredDevice;
pub use local::LocalFileHit;
pub use nodes::{SearchHit, StoredNode};
pub use ops::{
LOCAL_VOLUME,
AttachedBlob, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingCounts,
PendingOp, op_supersedes,
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH,
PendingCounts, PendingOp, op_supersedes,
};
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
pub use sync::{StoredSyncEntry, StoredSyncFolder};
-2
View File
@@ -223,10 +223,8 @@ impl Db {
// separate. `last_accessed` (unix seconds) is the LRU key. The daemon owns
// the on-disk cache and rebuilds this index from disk on open, then keeps it
// in sync on every store/read/evict, so it is authoritative for eviction.
}
/// Effective plaintext size of a node for the indexed `size` column: the
/// claimed size when known, else the on-storage size; folders are 0.
pub(super) fn node_size(node: &Node) -> i64 {
+3 -4
View File
@@ -317,9 +317,9 @@ impl Db {
Ok(PendingCounts { uploads, changes })
}
/// Nothing bearing this volume may be handed to the API — it would 404. The
/// drain replaces it with the uid the server assigns.
pub const LOCAL_VOLUME: &str = "local";
/// Nothing bearing this volume may be handed to the API — it would 404. The
/// drain replaces it with the uid the server assigns.
pub const LOCAL_VOLUME: &str = "local";
/// Drop a queued op, once its upload has actually landed.
pub fn delete_op(&self, id: i64) -> Result<()> {
@@ -340,5 +340,4 @@ pub const LOCAL_VOLUME: &str = "local";
)?;
Ok(())
}
}
+16 -13
View File
@@ -47,8 +47,7 @@ impl Db {
// any previously learned value so the Photos/Videos/Raw split survives a
// refresh instead of collapsing back to name-extension guesses.
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
let mut stmt =
tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
let rows = stmt.query_map([], |r| {
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
})?;
@@ -63,18 +62,17 @@ impl Db {
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)?;
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
let (ratio, thumb_state, learned_media) = learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None));
let (ratio, thumb_state, learned_media) =
learned
.get(uid)
.cloned()
.unwrap_or((None, THUMB_UNKNOWN, None));
let media_type = media_type.clone().or(learned_media);
// The tab this photo lands in is derived here, once, so a page or
// count query is a plain indexed `WHERE kind = ?` rather than a
// reclassification of every row.
let kind = crate::control::PhotoKind::classify(
name.as_deref(),
media_type.as_deref(),
);
let kind =
crate::control::PhotoKind::classify(name.as_deref(), media_type.as_deref());
stmt.execute(params![
uid,
capture_time,
@@ -127,7 +125,9 @@ impl Db {
let limit_pos = binds.len();
binds.push(offset as i64);
let offset_pos = binds.len();
sql.push_str(&format!(" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"));
sql.push_str(&format!(
" ORDER BY seq LIMIT ?{limit_pos} OFFSET ?{offset_pos}"
));
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
@@ -168,7 +168,11 @@ impl Db {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(binds), |r| {
Ok((r.get::<_, i32>(0)?, r.get::<_, i32>(1)?, r.get::<_, i64>(2)? as usize))
Ok((
r.get::<_, i32>(0)?,
r.get::<_, i32>(1)?,
r.get::<_, i64>(2)? as usize,
))
})?
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
@@ -240,5 +244,4 @@ impl Db {
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
Ok(n.max(0) as usize)
}
}
-3
View File
@@ -6,8 +6,6 @@ use rusqlite::{OptionalExtension, params};
use super::Db;
use crate::Result;
impl Db {
pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> {
let conn = self.conn.lock();
@@ -120,5 +118,4 @@ impl Db {
}
// ---- local (non-Drive) file index (schema v6) ----
}
-1
View File
@@ -94,5 +94,4 @@ impl Db {
conn.execute("DELETE FROM sync_state WHERE key = ?1", params![key])?;
Ok(())
}
}
-1
View File
@@ -222,5 +222,4 @@ impl Db {
)?;
Ok(())
}
}
File diff suppressed because it is too large. Load diff
-1
View File
@@ -61,5 +61,4 @@ impl Db {
}
// ---- device sync (devices.md) -----------------------------------------
}
+146 -1
View File
@@ -93,6 +93,27 @@ impl CoreError {
Self::new(ErrorKind::Internal, what)
}
/// Classify a failure from an SDK call, prefixing `what` for the log.
///
/// Takes `&dyn Error` rather than `&ProtonError` so it reads the same
/// through the boxes the drain deals in, where the concrete type survives
/// but the static one does not.
///
/// A failure that is not an API response at all — a transport error, a
/// timeout, a TLS failure — is what being offline looks like from here, so
/// it classifies as [`ErrorKind::Offline`] rather than
/// [`ErrorKind::Remote`]: the request never reached Proton, and telling the
/// user their connection is down beats telling them the server refused.
pub fn from_api(e: &(dyn std::error::Error + 'static), what: &str) -> Self {
let kind = match e.downcast_ref::<proton_sdk::ProtonError>() {
Some(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
// A `ProtonError` that is not an `Api` variant never reached the
// API; so does anything that is not a `ProtonError` at all.
_ => ErrorKind::Offline,
};
Self::new(kind, format!("{what}: {e}"))
}
/// Prefix the message while keeping the classification.
///
/// For adding the caller's context to an already-classified failure — the
@@ -134,12 +155,136 @@ impl From<Error> for CoreError {
fn from(e: Error) -> Self {
let kind = match &e {
Error::NotLoggedIn | Error::ReloginRequired => ErrorKind::Denied,
Error::Proton(_) => ErrorKind::Remote,
Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code),
// A `ProtonError` that never reached the API is a transport failure.
Error::Proton(_) => ErrorKind::Offline,
_ => ErrorKind::Internal,
};
Self::new(kind, e.to_string())
}
}
/// What an API response code means to a front-end.
///
/// The grouping is by *what the user can do about it*, which is what
/// [`ErrorKind`] exists to express — not by HTTP-ish similarity. So a 403 and a
/// `NotEnoughPermissions` land together despite being different numbers, while
/// `AlreadyExists` and `DoesNotExist` stay apart despite both being 25xx.
fn api_kind(code: proton_sdk::api::ResponseCode) -> ErrorKind {
use proton_sdk::api::ResponseCode as C;
match code {
// The account may not do this, and repeating it will not help.
C::Unauthorized
| C::Forbidden
| C::NotEnoughPermissions
| C::NotEnoughPermissionsToGrantPermissions
| C::InsufficientScope
| C::IncorrectLoginCredentials
| C::InvalidRefreshToken
| C::AccountDeleted
| C::AccountDisabled
| C::NoActiveSubscription => ErrorKind::Denied,
C::DoesNotExist => ErrorKind::NotFound,
// The remote moved underneath the request; the caller has to decide.
C::AlreadyExists | C::IncompatibleState => ErrorKind::Conflict,
// The request was malformed or asks for something structurally
// impossible. A bug in the caller, not a condition to retry.
C::InvalidRequirements
| C::InvalidValue
| C::InvalidEncryptedIdFormat
| C::InvalidApp
| C::TooManyChildren
| C::NestingTooDeep => ErrorKind::Invalid,
C::InsufficientQuota | C::InsufficientSpace | C::InsufficientVolumeQuota => {
ErrorKind::Quota
}
// Reached Proton, but it could not answer *now*. Retrying is exactly
// the right response, so these are Offline rather than Remote — the
// front-end already words that as "not right now".
C::RequestTimeout
| C::Timeout
| C::TooManyRequests
| C::ServiceUnavailable
| C::Offline => ErrorKind::Offline,
// Reached Proton and it refused for a reason we have no specific
// handling for. Retryable, because an unchanged retry can legitimately
// succeed against a transient server-side condition.
_ => ErrorKind::Remote,
}
}
/// The result every request-serving method in the daemon returns.
pub type CoreResult<T> = std::result::Result<T, CoreError>;
#[cfg(test)]
mod tests {
use super::*;
use proton_sdk::api::ResponseCode as C;
use proton_sdk::{ProtonApiError, ProtonError};
fn api(code: C) -> ProtonError {
ProtonError::Api(ProtonApiError {
code,
http_status: 422,
message: "nope".into(),
details: None,
})
}
#[test]
fn api_codes_classify_by_what_the_user_can_do() {
assert_eq!(api_kind(C::Forbidden), ErrorKind::Denied);
assert_eq!(api_kind(C::NotEnoughPermissions), ErrorKind::Denied);
assert_eq!(api_kind(C::DoesNotExist), ErrorKind::NotFound);
assert_eq!(api_kind(C::AlreadyExists), ErrorKind::Conflict);
assert_eq!(api_kind(C::InvalidValue), ErrorKind::Invalid);
assert_eq!(api_kind(C::InsufficientSpace), ErrorKind::Quota);
assert_eq!(api_kind(C::TooManyRequests), ErrorKind::Offline);
// Nothing specific known about it, but Proton did answer.
assert_eq!(api_kind(C::ProtonDriveUnknown), ErrorKind::Remote);
}
/// The regression this whole pass exists to prevent: before it, every SDK
/// failure was hardcoded to `Remote`, so a 403 asked the user to retry a
/// request that could never succeed.
#[test]
fn a_forbidden_response_is_not_offered_as_retryable() {
let e = CoreError::from_api(&api(C::Forbidden), "rename");
assert_eq!(e.kind, ErrorKind::Denied);
assert!(!e.kind.retryable());
assert!(e.message.starts_with("rename: "));
}
/// Being full is not a transient condition, so retrying an upload that did
/// not fit must not be offered as a fix.
#[test]
fn running_out_of_space_is_not_retryable() {
assert!(
!CoreError::from_api(&api(C::InsufficientQuota), "upload")
.kind
.retryable()
);
}
/// A failure that never reached the API is the user's connection, not
/// Proton refusing — the front-end words those differently.
#[test]
fn a_transport_failure_reads_as_offline() {
let io = std::io::Error::other("connection reset");
assert_eq!(CoreError::from_api(&io, "list").kind, ErrorKind::Offline);
}
/// `Error` is the plumbing type; converting it must classify as sharply as
/// the direct path, or the two disagree about the same failure.
#[test]
fn converting_from_the_plumbing_error_classifies_the_api_code() {
let e: CoreError = Error::Proton(api(C::DoesNotExist)).into();
assert_eq!(e.kind, ErrorKind::NotFound);
}
}