Skip to content

Commit 469aaa7

Browse files
committed
Add access error tracking and resilient scanning
- Add access column to items table tracking permission issues (Ok, MetaError, ReadError) - Track access state changes in change records (access_old, access_new) - Generate Access Denied alerts when files cannot be read during analysis - Permission errors no longer abort scans - items are tracked and can be retried - Add Access Denied alert type to web UI with amber warning badge - Add Access Denied filter option to Alerts page dropdown - Remove BufReader wrapper from hash computation for efficiency - Database schema v12 migration
1 parent edc44b2 commit 469aaa7

File tree

24 files changed

+1743
-452
lines changed

24 files changed

+1743
-452
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Access error tracking**: New `access` column on items tracks permission issues encountered during scanning
12+
- `Ok`: No access issues
13+
- `MetaError`: Unable to read file metadata (detected during scan phase)
14+
- `ReadError`: Unable to read file contents (detected during analysis phase)
15+
- **Access Denied alerts**: New alert type generated when files cannot be read during analysis
16+
- Displayed with amber "warning" badge in web UI to distinguish from validation errors
17+
- Filterable via new "Access Denied" option in Alerts page type dropdown
18+
- **Change tracking for access state**: Change records now include `access_old` and `access_new` columns to track permission changes over time
19+
20+
### Changed
21+
- **Resilient scanning**: Permission errors no longer abort scans. Items with access issues are tracked and can be retried on subsequent scans when permissions are restored
22+
- **Hashing optimization**: Removed unnecessary `BufReader` wrapper for more efficient large file hashing
23+
1024
## [v0.3.1] - 2025-11-23
1125

1226
### Changed

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ r2d2_sqlite = "0.31"
4949
rusqlite = { version = "0.37", features = ["bundled", "collation"] }
5050
serde = { version = "1.0", features = ["derive"] }
5151
serde_json = "1.0"
52-
sha2 = "0.10"
52+
sha2 = { version = "0.10", features = ["asm"] }
5353
strum = "0.27"
5454
thiserror = "2.0"
5555
tabled = "0.20"
@@ -93,3 +93,8 @@ codegen-units = 1 # PNG decoder (used by image crate)
9393

9494
[profile.release.package.lopdf]
9595
codegen-units = 1 # PDF parsing - slow in this application's usage pattern
96+
97+
# Optimize dependencies in dev builds for faster debugging experience
98+
# Your code remains fully debuggable; only dependencies are optimized
99+
[profile.dev.package."*"]
100+
opt-level = 3

frontend/src/components/shared/ItemDetailSheet.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,8 @@ export function ItemDetailSheet({
471471
return <Badge variant="destructive">Suspicious Hash</Badge>
472472
case 'I':
473473
return <Badge variant="destructive">Invalid Item</Badge>
474+
case 'A':
475+
return <Badge variant="warning">Access Denied</Badge>
474476
default:
475477
return <Badge variant="secondary">{type}</Badge>
476478
}

frontend/src/lib/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ export interface ScheduleWithRoot extends Schedule {
226226
// Insights Page Types
227227

228228
export type AlertStatusValue = 'O' | 'F' | 'D' // Open, Flagged, Dismissed
229-
export type AlertTypeValue = 'H' | 'I' // Suspicious Hash, Invalid Item
229+
export type AlertTypeValue = 'H' | 'I' | 'A' // Suspicious Hash, Invalid Item, Access Denied
230230
export type ContextFilterType = 'all' | 'root' | 'scan'
231231

232232
export interface UpdateAlertStatusRequest {

frontend/src/pages/alerts/AlertsPage.tsx

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -292,24 +292,30 @@ export function AlertsPage() {
292292
}
293293

294294
const getAlertTypeBadge = (type: AlertTypeValue) => {
295-
if (type === 'H') {
296-
return <Badge variant="error">Suspicious Hash</Badge>
297-
} else {
298-
return <Badge variant="error">Invalid Item</Badge>
295+
switch (type) {
296+
case 'H':
297+
return <Badge variant="error">Suspicious Hash</Badge>
298+
case 'I':
299+
return <Badge variant="error">Invalid Item</Badge>
300+
case 'A':
301+
return <Badge variant="warning">Access Denied</Badge>
299302
}
300303
}
301304

302305
const getAlertDetails = (alert: AlertRow) => {
303-
if (alert.alert_type === 'H') {
304-
return (
305-
<div className="text-xs space-y-1">
306-
<div>Hash changed</div>
307-
<div className="font-mono text-muted-foreground">Old: {alert.hash_old || 'N/A'}</div>
308-
<div className="font-mono text-muted-foreground">New: {alert.hash_new || 'N/A'}</div>
309-
</div>
310-
)
311-
} else {
312-
return <div className="text-xs">{alert.val_error || 'Validation error'}</div>
306+
switch (alert.alert_type) {
307+
case 'H':
308+
return (
309+
<div className="text-xs space-y-1">
310+
<div>Hash changed</div>
311+
<div className="font-mono text-muted-foreground">Old: {alert.hash_old || 'N/A'}</div>
312+
<div className="font-mono text-muted-foreground">New: {alert.hash_new || 'N/A'}</div>
313+
</div>
314+
)
315+
case 'I':
316+
return <div className="text-xs">{alert.val_error || 'Validation error'}</div>
317+
case 'A':
318+
return <div className="text-xs">File could not be read</div>
313319
}
314320
}
315321

@@ -361,6 +367,7 @@ export function AlertsPage() {
361367
<SelectItem value="all">All Types</SelectItem>
362368
<SelectItem value="H">Suspicious Hash</SelectItem>
363369
<SelectItem value="I">Invalid Item</SelectItem>
370+
<SelectItem value="A">Access Denied</SelectItem>
364371
</SelectContent>
365372
</Select>
366373
</div>

src/alerts.rs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::error::FsPulseError;
99
pub enum AlertType {
1010
SuspiciousHash = 0,
1111
InvalidItem = 1,
12+
AccessDenied = 2,
1213
}
1314

1415
#[repr(i64)]
@@ -28,6 +29,7 @@ impl AlertType {
2829
match value {
2930
0 => AlertType::SuspiciousHash,
3031
1 => AlertType::InvalidItem,
32+
2 => AlertType::AccessDenied,
3133
_ => {
3234
warn!(
3335
"Invalid AlertType value in database: {}, defaulting to SuspiciousHash",
@@ -42,13 +44,15 @@ impl AlertType {
4244
match self {
4345
AlertType::SuspiciousHash => "H",
4446
AlertType::InvalidItem => "I",
47+
AlertType::AccessDenied => "A",
4548
}
4649
}
4750

4851
pub fn full_name(&self) -> &'static str {
4952
match self {
5053
AlertType::SuspiciousHash => "Suspicious Hash",
5154
AlertType::InvalidItem => "Invalid Item",
55+
AlertType::AccessDenied => "Access Denied",
5256
}
5357
}
5458

@@ -57,9 +61,11 @@ impl AlertType {
5761
// Full names
5862
"SUSPICIOUS HASH" | "SUSPICIOUSHASH" => Some(AlertType::SuspiciousHash),
5963
"INVALID ITEM" | "INVALIDITEM" => Some(AlertType::InvalidItem),
64+
"ACCESS DENIED" | "ACCESSDENIED" => Some(AlertType::AccessDenied),
6065
// Short names
6166
"H" => Some(AlertType::SuspiciousHash),
6267
"I" => Some(AlertType::InvalidItem),
68+
"A" => Some(AlertType::AccessDenied),
6369
_ => None,
6470
}
6571
}
@@ -258,6 +264,43 @@ impl Alerts {
258264
Ok(())
259265
}
260266

267+
/// Create an alert when an item becomes inaccessible.
268+
/// This is called when access transitions from Ok to MetaError or ReadError.
269+
pub fn add_access_denied_alert(
270+
conn: &Connection,
271+
scan_id: i64,
272+
item_id: i64,
273+
) -> Result<(), FsPulseError> {
274+
let sql = r#"
275+
INSERT INTO alerts (
276+
alert_type,
277+
alert_status,
278+
scan_id,
279+
item_id,
280+
created_at
281+
)
282+
VALUES (
283+
:alert_type,
284+
:alert_status,
285+
:scan_id,
286+
:item_id,
287+
strftime('%s', 'now', 'utc')
288+
)
289+
"#;
290+
291+
conn.execute(
292+
sql,
293+
named_params! {
294+
":alert_type": AlertType::AccessDenied.as_i64(),
295+
":alert_status": AlertStatus::Open.as_i64(),
296+
":scan_id": scan_id,
297+
":item_id": item_id,
298+
},
299+
)?;
300+
301+
Ok(())
302+
}
303+
261304
pub fn set_alert_status(
262305
conn: &Connection,
263306
alert_id: i64,
@@ -290,13 +333,15 @@ mod tests {
290333
// Verify the integer values match the expected order
291334
assert_eq!(AlertType::SuspiciousHash.as_i64(), 0);
292335
assert_eq!(AlertType::InvalidItem.as_i64(), 1);
336+
assert_eq!(AlertType::AccessDenied.as_i64(), 2);
293337
}
294338

295339
#[test]
296340
fn test_alert_type_from_i64() {
297341
// Verify round-trip conversion
298342
assert_eq!(AlertType::from_i64(0), AlertType::SuspiciousHash);
299343
assert_eq!(AlertType::from_i64(1), AlertType::InvalidItem);
344+
assert_eq!(AlertType::from_i64(2), AlertType::AccessDenied);
300345

301346
// Invalid values should default to SuspiciousHash
302347
assert_eq!(AlertType::from_i64(999), AlertType::SuspiciousHash);
@@ -307,23 +352,28 @@ mod tests {
307352
fn test_alert_type_short_name() {
308353
assert_eq!(AlertType::SuspiciousHash.short_name(), "H");
309354
assert_eq!(AlertType::InvalidItem.short_name(), "I");
355+
assert_eq!(AlertType::AccessDenied.short_name(), "A");
310356
}
311357

312358
#[test]
313359
fn test_alert_type_full_name() {
314360
assert_eq!(AlertType::SuspiciousHash.full_name(), "Suspicious Hash");
315361
assert_eq!(AlertType::InvalidItem.full_name(), "Invalid Item");
362+
assert_eq!(AlertType::AccessDenied.full_name(), "Access Denied");
316363
}
317364

318365
#[test]
319366
fn test_alert_type_traits() {
320367
let suspicious = AlertType::SuspiciousHash;
321368
let invalid = AlertType::InvalidItem;
369+
let access = AlertType::AccessDenied;
322370

323371
// Test PartialEq
324372
assert_eq!(suspicious, AlertType::SuspiciousHash);
325373
assert_eq!(invalid, AlertType::InvalidItem);
374+
assert_eq!(access, AlertType::AccessDenied);
326375
assert_ne!(suspicious, invalid);
376+
assert_ne!(invalid, access);
327377

328378
// Test Copy
329379
let suspicious_copy = suspicious;
@@ -402,7 +452,11 @@ mod tests {
402452
#[test]
403453
fn test_alert_type_completeness() {
404454
// Verify we can convert all enum variants to strings
405-
let all_types = [AlertType::SuspiciousHash, AlertType::InvalidItem];
455+
let all_types = [
456+
AlertType::SuspiciousHash,
457+
AlertType::InvalidItem,
458+
AlertType::AccessDenied,
459+
];
406460

407461
for alert_type in all_types {
408462
let short_str = alert_type.short_name();

src/changes.rs

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ pub struct Change {
88
pub scan_id: i64,
99
pub item_id: i64,
1010
pub change_type: ChangeType,
11+
pub access_old: Option<i64>, // Previous access state (if changed)
12+
pub access_new: Option<i64>, // New access state (if changed)
1113
pub is_undelete: Option<bool>, // Present if "A". True if add is undelete
1214
pub meta_change: Option<bool>, // Present if "M". True if metadata changed, else False
1315
pub mod_date_old: Option<i64>, // Meaningful if undelete or meta_change
1416
pub mod_date_new: Option<i64>, // Meaningful if metdata_changed
15-
pub size_old: Option<i64>, // Meaningful if undelete or meta_change
16-
pub size_new: Option<i64>, // Meaningful if undelete or meta_change
17+
pub size_old: Option<i64>, // Meaningful if undelete or meta_change
18+
pub size_new: Option<i64>, // Meaningful if undelete or meta_change
1719
pub hash_change: Option<bool>, // Present if "M". True if hash changed, else False
1820
#[allow(dead_code)]
1921
pub last_hash_scan_old: Option<i64>, // Present if "M" and hash_change
@@ -23,7 +25,7 @@ pub struct Change {
2325
pub val_change: Option<bool>, // Present if "M", True if validation changed, else False
2426
#[allow(dead_code)]
2527
pub last_val_scan_old: Option<i64>, // Present if "M" and validation changed
26-
pub val_old: Option<i64>, // Validation state of the item if val_change = true
28+
pub val_old: Option<i64>, // Validation state of the item if val_change = true
2729
#[allow(dead_code)]
2830
pub val_new: Option<i64>, // Meaningful if undelete or val_change
2931
#[allow(dead_code)]
@@ -59,7 +61,10 @@ impl ChangeType {
5961
2 => ChangeType::Modify,
6062
3 => ChangeType::Delete,
6163
_ => {
62-
warn!("Invalid ChangeType value in database: {}, defaulting to NoChange", value);
64+
warn!(
65+
"Invalid ChangeType value in database: {}, defaulting to NoChange",
66+
value
67+
);
6368
ChangeType::NoChange
6469
}
6570
}
@@ -161,15 +166,15 @@ mod tests {
161166
assert_eq!(ChangeType::Modify.short_name(), "M");
162167
assert_eq!(ChangeType::NoChange.short_name(), "N");
163168
}
164-
169+
165170
#[test]
166171
fn test_change_type_display() {
167172
assert_eq!(ChangeType::Add.to_string(), "Add");
168173
assert_eq!(ChangeType::Delete.to_string(), "Delete");
169174
assert_eq!(ChangeType::Modify.to_string(), "Modify");
170175
assert_eq!(ChangeType::NoChange.to_string(), "No Change");
171176
}
172-
177+
173178
#[test]
174179
fn test_change_type_from_string() {
175180
assert_eq!(ChangeType::from_string("A"), Some(ChangeType::Add));
@@ -186,21 +191,29 @@ mod tests {
186191

187192
#[test]
188193
fn test_change_type_round_trip() {
189-
let types = [ChangeType::NoChange, ChangeType::Add, ChangeType::Modify, ChangeType::Delete];
194+
let types = [
195+
ChangeType::NoChange,
196+
ChangeType::Add,
197+
ChangeType::Modify,
198+
ChangeType::Delete,
199+
];
190200

191201
for change_type in types {
192202
let str_val = change_type.short_name();
193203
let parsed_back = ChangeType::from_string(str_val).unwrap();
194-
assert_eq!(change_type, parsed_back, "Round trip failed for {change_type:?}");
204+
assert_eq!(
205+
change_type, parsed_back,
206+
"Round trip failed for {change_type:?}"
207+
);
195208
}
196209
}
197-
210+
198211
#[test]
199212
fn test_change_type_copy_clone() {
200213
let change_type = ChangeType::Add;
201214
let change_type_copy = change_type;
202215
let change_type_clone = change_type;
203-
216+
204217
// All should be equal
205218
assert_eq!(change_type, change_type_copy);
206219
assert_eq!(change_type, change_type_clone);

0 commit comments

Comments
 (0)