feat: add enhanced error messages for file operations (#310)

* feat: add enhanced error messages for file operations

Add a file_errors module that provides context-aware error messages
for file operations. When file operations fail, users now see:
- The specific operation that failed (create, read, write, rename, etc.)
- The full path involved
- Helpful hints about possible causes based on error type
- Android-specific hints for permission errors (SELinux, Work Profile,
  MDM/Knox policies, app cloning features)

This helps diagnose issues like "Permission denied (os error 13)" by
indicating which operation failed and suggesting possible causes.
This commit is contained in:
Brandon DeRosier
2026-02-04 12:36:39 -08:00
committed by GitHub
parent 08fb9df932
commit eeec42efb7
7 changed files with 541 additions and 26 deletions
+6 -3
View File
@@ -1,4 +1,5 @@
use anyhow::{bail, Context};
use crate::file_errors::{FileOperation, IoResultExt};
use serde::{de::DeserializeOwned, Serialize};
use std::{
fs::File,
@@ -21,9 +22,10 @@ where
// Because File::create can sometimes fail if the full directory path doesn't exist,
// we create the directories in its path first.
std::fs::create_dir_all(containing_dir)
.with_context(|| format!("Failed to create dir {:?}", path_as_ref))?;
.with_file_context(FileOperation::CreateDir, containing_dir)?;
let file = File::create(path).with_context(|| format!("File::create for {:?}", path_as_ref))?;
let file = File::create(path)
.with_file_context(FileOperation::CreateFile, path_as_ref)?;
let writer = BufWriter::new(file);
serde_json::to_writer_pretty(writer, serializable)
.with_context(|| format!("failed to serialize to {:?}", path_as_ref))
@@ -41,7 +43,8 @@ where
bail!("File {} does not exist", path_as_ref.display());
}
let file = File::open(path_as_ref)?;
let file = File::open(path_as_ref)
.with_file_context(FileOperation::ReadFile, path_as_ref)?;
let reader = BufReader::new(file);
serde_json::from_reader(reader)
.with_context(|| format!("failed to deserialize from {:?}", &path_as_ref))
+14 -12
View File
@@ -1,6 +1,7 @@
use super::{disk_io, signing, PatchInfo};
use crate::yaml::PatchVerificationMode;
use anyhow::{bail, Context, Result};
use crate::file_errors::{FileOperation, IoResultExt};
use core::fmt::Debug;
use serde::{Deserialize, Serialize};
use std::{
@@ -231,7 +232,8 @@ impl PatchManager {
);
}
let artifact_size_on_disk = std::fs::metadata(&artifact_path)?.len();
let artifact_size_on_disk = std::fs::metadata(&artifact_path)
.with_file_context(FileOperation::GetMetadata, &artifact_path)?.len();
if artifact_size_on_disk != patch.size {
bail!(
"Patch {} has size {} on disk, but expected size {}",
@@ -275,7 +277,7 @@ impl PatchManager {
shorebird_error!("Failed to delete patch dir {}: {}", patch_dir.display(), e);
e
})
.with_context(|| format!("Failed to delete patch dir {}", &patch_dir.display()))
.with_file_context(FileOperation::DeleteDir, &patch_dir)
}
/// Deletes artifacts for the provided bad_patch_number and attempts to set the next_boot_patch to the last
@@ -392,14 +394,17 @@ impl ManagePatches for PatchManager {
let patch_path = self.patch_artifact_path(patch_number);
std::fs::create_dir_all(self.patch_dir(patch_number))
.with_context(|| format!("create_dir_all failed for {}", patch_path.display()))?;
let patch_dir = self.patch_dir(patch_number);
std::fs::create_dir_all(&patch_dir)
.with_file_context(FileOperation::CreateDir, &patch_dir)?;
std::fs::rename(file_path, &patch_path)?;
std::fs::rename(file_path, &patch_path)
.with_file_context(FileOperation::RenameFile, file_path)?;
let new_patch = PatchMetadata {
number: patch_number,
size: std::fs::metadata(&patch_path)?.len(),
size: std::fs::metadata(&patch_path)
.with_file_context(FileOperation::GetMetadata, &patch_path)?.len(),
hash: hash.to_owned(),
signature: signature.map(|s| s.to_owned()),
};
@@ -524,12 +529,9 @@ impl ManagePatches for PatchManager {
fn reset(&mut self) -> Result<()> {
self.patches_state = PatchesState::default();
self.save_patches_state()?;
std::fs::remove_dir_all(self.patches_dir()).with_context(|| {
format!(
"Failed to delete patches dir {}",
self.patches_dir().display()
)
})
let patches_dir = self.patches_dir();
std::fs::remove_dir_all(&patches_dir)
.with_file_context(FileOperation::DeleteDir, &patches_dir)
}
}
+495
View File
@@ -0,0 +1,495 @@
// This module provides enhanced error messages for file operations.
// It detects specific error types and provides more helpful context.
use std::io::ErrorKind;
use std::path::Path;
/// Describes the type of file operation that failed.
#[derive(Debug, Clone, Copy)]
pub enum FileOperation {
CreateDir,
CreateFile,
WriteFile,
ReadFile,
DeleteFile,
DeleteDir,
RenameFile,
GetMetadata,
}
impl std::fmt::Display for FileOperation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileOperation::CreateDir => write!(f, "create directory"),
FileOperation::CreateFile => write!(f, "create file"),
FileOperation::WriteFile => write!(f, "write to file"),
FileOperation::ReadFile => write!(f, "read file"),
FileOperation::DeleteFile => write!(f, "delete file"),
FileOperation::DeleteDir => write!(f, "delete directory"),
FileOperation::RenameFile => write!(f, "rename/move file"),
FileOperation::GetMetadata => write!(f, "get file metadata"),
}
}
}
/// Creates an enhanced error message for a file operation failure.
///
/// This function takes an IO error and adds context about what operation failed,
/// what path was involved, and provides hints about possible causes based on
/// the error type.
pub fn enhance_io_error(
error: &std::io::Error,
operation: FileOperation,
path: &Path,
) -> String {
let base_message = format!(
"Failed to {} '{}': {}",
operation,
path.display(),
error
);
let hint = get_error_hint(error, operation);
if hint.is_empty() {
base_message
} else {
format!("{}\nPossible cause: {}", base_message, hint)
}
}
/// Returns a hint about possible causes for the given error type.
fn get_error_hint(error: &std::io::Error, operation: FileOperation) -> String {
match error.kind() {
ErrorKind::PermissionDenied => get_permission_denied_hint(operation),
ErrorKind::NotFound => get_not_found_hint(operation),
ErrorKind::AlreadyExists => {
"A file or directory with this name already exists.".to_string()
}
ErrorKind::StorageFull => {
"The device storage is full. Free up space and try again.".to_string()
}
ErrorKind::ReadOnlyFilesystem => {
"The filesystem is mounted as read-only.".to_string()
}
_ => {
// Check raw OS error for cases not covered by ErrorKind
if let Some(os_error) = error.raw_os_error() {
get_os_error_hint(os_error)
} else {
String::new()
}
}
}
}
/// Returns hints specific to permission denied errors.
fn get_permission_denied_hint(operation: FileOperation) -> String {
match operation {
FileOperation::CreateDir | FileOperation::CreateFile | FileOperation::WriteFile => {
"The app may not have write access to this location.".to_string()
}
FileOperation::ReadFile => {
"The app may not have read access to this file.".to_string()
}
FileOperation::DeleteFile | FileOperation::DeleteDir => {
"The app may not have permission to delete this item.".to_string()
}
FileOperation::RenameFile => {
"The app may not have permission to move files in this location.".to_string()
}
FileOperation::GetMetadata => {
"The app may not have permission to access this file's metadata.".to_string()
}
}
}
/// Returns hints specific to not found errors.
fn get_not_found_hint(operation: FileOperation) -> String {
match operation {
FileOperation::CreateDir | FileOperation::CreateFile | FileOperation::WriteFile => {
"The parent directory may not exist.".to_string()
}
FileOperation::RenameFile => {
"The source file or destination directory may not exist.".to_string()
}
_ => {
"The file or directory does not exist.".to_string()
}
}
}
/// Returns hints for specific OS error codes not covered by ErrorKind.
fn get_os_error_hint(os_error: i32) -> String {
// Unix/Linux error codes
match os_error {
28 => "The device storage is full (ENOSPC). Free up space and try again.".to_string(),
30 => "The filesystem is mounted as read-only (EROFS).".to_string(),
122 => "Disk quota exceeded (EDQUOT). The user's storage quota has been reached.".to_string(),
_ => String::new(),
}
}
/// A trait extension for adding enhanced context to IO Results.
pub trait IoResultExt<T> {
/// Adds enhanced error context to an IO operation result.
fn with_file_context(self, operation: FileOperation, path: &Path) -> anyhow::Result<T>;
}
impl<T> IoResultExt<T> for std::io::Result<T> {
fn with_file_context(self, operation: FileOperation, path: &Path) -> anyhow::Result<T> {
self.map_err(|e| {
let enhanced_message = enhance_io_error(&e, operation, path);
anyhow::Error::new(e).context(enhanced_message)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Error, ErrorKind};
// ==================== FileOperation Display Tests ====================
#[test]
fn test_operation_display_all_variants() {
assert_eq!(format!("{}", FileOperation::CreateDir), "create directory");
assert_eq!(format!("{}", FileOperation::CreateFile), "create file");
assert_eq!(format!("{}", FileOperation::WriteFile), "write to file");
assert_eq!(format!("{}", FileOperation::ReadFile), "read file");
assert_eq!(format!("{}", FileOperation::DeleteFile), "delete file");
assert_eq!(format!("{}", FileOperation::DeleteDir), "delete directory");
assert_eq!(format!("{}", FileOperation::RenameFile), "rename/move file");
assert_eq!(format!("{}", FileOperation::GetMetadata), "get file metadata");
}
// ==================== enhance_io_error Tests ====================
#[test]
fn test_enhance_io_error_includes_operation_path_and_error() {
let error = Error::new(ErrorKind::Other, "some error");
let path = Path::new("/some/path/file.txt");
let message = enhance_io_error(&error, FileOperation::ReadFile, path);
assert!(message.contains("Failed to read file"));
assert!(message.contains("/some/path/file.txt"));
assert!(message.contains("some error"));
}
#[test]
fn test_enhance_io_error_no_hint_for_unknown_error() {
let error = Error::new(ErrorKind::Other, "unknown error");
let path = Path::new("/path/file.txt");
let message = enhance_io_error(&error, FileOperation::ReadFile, path);
// Should not contain "Possible cause" for unknown errors
assert!(!message.contains("Possible cause"));
}
// ==================== Permission Denied Tests ====================
#[test]
fn test_permission_denied_create_dir() {
let error = Error::new(ErrorKind::PermissionDenied, "Permission denied");
let path = Path::new("/protected/dir");
let message = enhance_io_error(&error, FileOperation::CreateDir, path);
assert!(message.contains("Failed to create directory"));
assert!(message.contains("write access"));
}
#[test]
fn test_permission_denied_create_file() {
let error = Error::new(ErrorKind::PermissionDenied, "Permission denied");
let path = Path::new("/protected/file.txt");
let message = enhance_io_error(&error, FileOperation::CreateFile, path);
assert!(message.contains("Failed to create file"));
assert!(message.contains("write access"));
}
#[test]
fn test_permission_denied_write_file() {
let error = Error::new(ErrorKind::PermissionDenied, "Permission denied");
let path = Path::new("/protected/file.txt");
let message = enhance_io_error(&error, FileOperation::WriteFile, path);
assert!(message.contains("Failed to write to file"));
assert!(message.contains("write access"));
}
#[test]
fn test_permission_denied_read_file() {
let error = Error::new(ErrorKind::PermissionDenied, "Permission denied");
let path = Path::new("/protected/file.txt");
let message = enhance_io_error(&error, FileOperation::ReadFile, path);
assert!(message.contains("Failed to read file"));
assert!(message.contains("read access"));
}
#[test]
fn test_permission_denied_delete_file() {
let error = Error::new(ErrorKind::PermissionDenied, "Permission denied");
let path = Path::new("/protected/file.txt");
let message = enhance_io_error(&error, FileOperation::DeleteFile, path);
assert!(message.contains("Failed to delete file"));
assert!(message.contains("permission to delete"));
}
#[test]
fn test_permission_denied_delete_dir() {
let error = Error::new(ErrorKind::PermissionDenied, "Permission denied");
let path = Path::new("/protected/dir");
let message = enhance_io_error(&error, FileOperation::DeleteDir, path);
assert!(message.contains("Failed to delete directory"));
assert!(message.contains("permission to delete"));
}
#[test]
fn test_permission_denied_rename_file() {
let error = Error::new(ErrorKind::PermissionDenied, "Permission denied");
let path = Path::new("/protected/file.txt");
let message = enhance_io_error(&error, FileOperation::RenameFile, path);
assert!(message.contains("Failed to rename/move file"));
assert!(message.contains("permission to move"));
}
#[test]
fn test_permission_denied_get_metadata() {
let error = Error::new(ErrorKind::PermissionDenied, "Permission denied");
let path = Path::new("/protected/file.txt");
let message = enhance_io_error(&error, FileOperation::GetMetadata, path);
assert!(message.contains("Failed to get file metadata"));
assert!(message.contains("permission to access"));
assert!(message.contains("metadata"));
}
// ==================== Not Found Tests ====================
#[test]
fn test_not_found_create_dir() {
let error = Error::new(ErrorKind::NotFound, "No such file or directory");
let path = Path::new("/nonexistent/parent/newdir");
let message = enhance_io_error(&error, FileOperation::CreateDir, path);
assert!(message.contains("Failed to create directory"));
assert!(message.contains("parent directory may not exist"));
}
#[test]
fn test_not_found_create_file() {
let error = Error::new(ErrorKind::NotFound, "No such file or directory");
let path = Path::new("/nonexistent/parent/file.txt");
let message = enhance_io_error(&error, FileOperation::CreateFile, path);
assert!(message.contains("Failed to create file"));
assert!(message.contains("parent directory may not exist"));
}
#[test]
fn test_not_found_write_file() {
let error = Error::new(ErrorKind::NotFound, "No such file or directory");
let path = Path::new("/nonexistent/file.txt");
let message = enhance_io_error(&error, FileOperation::WriteFile, path);
assert!(message.contains("Failed to write to file"));
assert!(message.contains("parent directory may not exist"));
}
#[test]
fn test_not_found_read_file() {
let error = Error::new(ErrorKind::NotFound, "No such file or directory");
let path = Path::new("/nonexistent/file.txt");
let message = enhance_io_error(&error, FileOperation::ReadFile, path);
assert!(message.contains("Failed to read file"));
assert!(message.contains("does not exist"));
}
#[test]
fn test_not_found_delete_file() {
let error = Error::new(ErrorKind::NotFound, "No such file or directory");
let path = Path::new("/nonexistent/file.txt");
let message = enhance_io_error(&error, FileOperation::DeleteFile, path);
assert!(message.contains("Failed to delete file"));
assert!(message.contains("does not exist"));
}
#[test]
fn test_not_found_delete_dir() {
let error = Error::new(ErrorKind::NotFound, "No such file or directory");
let path = Path::new("/nonexistent/dir");
let message = enhance_io_error(&error, FileOperation::DeleteDir, path);
assert!(message.contains("Failed to delete directory"));
assert!(message.contains("does not exist"));
}
#[test]
fn test_not_found_rename_file() {
let error = Error::new(ErrorKind::NotFound, "No such file or directory");
let path = Path::new("/nonexistent/file.txt");
let message = enhance_io_error(&error, FileOperation::RenameFile, path);
assert!(message.contains("Failed to rename/move file"));
assert!(message.contains("source file or destination directory may not exist"));
}
#[test]
fn test_not_found_get_metadata() {
let error = Error::new(ErrorKind::NotFound, "No such file or directory");
let path = Path::new("/nonexistent/file.txt");
let message = enhance_io_error(&error, FileOperation::GetMetadata, path);
assert!(message.contains("Failed to get file metadata"));
assert!(message.contains("does not exist"));
}
// ==================== Other Error Kind Tests ====================
#[test]
fn test_already_exists_error() {
let error = Error::new(ErrorKind::AlreadyExists, "File exists");
let path = Path::new("/existing/file.txt");
let message = enhance_io_error(&error, FileOperation::CreateFile, path);
assert!(message.contains("Failed to create file"));
assert!(message.contains("already exists"));
}
#[test]
fn test_storage_full_error() {
let error = Error::new(ErrorKind::StorageFull, "No space left on device");
let path = Path::new("/data/file.txt");
let message = enhance_io_error(&error, FileOperation::WriteFile, path);
assert!(message.contains("Failed to write to file"));
assert!(message.contains("storage is full"));
assert!(message.contains("Free up space"));
}
#[test]
fn test_read_only_filesystem_error() {
let error = Error::new(ErrorKind::ReadOnlyFilesystem, "Read-only file system");
let path = Path::new("/readonly/file.txt");
let message = enhance_io_error(&error, FileOperation::WriteFile, path);
assert!(message.contains("Failed to write to file"));
assert!(message.contains("read-only"));
}
// ==================== OS Error Code Tests ====================
#[test]
fn test_get_os_error_hint_enospc() {
// Test the get_os_error_hint function directly for ENOSPC (28)
let hint = get_os_error_hint(28);
assert!(hint.contains("ENOSPC"));
assert!(hint.contains("storage is full"));
}
#[test]
fn test_get_os_error_hint_erofs() {
// Test the get_os_error_hint function directly for EROFS (30)
let hint = get_os_error_hint(30);
assert!(hint.contains("EROFS"));
assert!(hint.contains("read-only"));
}
#[test]
fn test_get_os_error_hint_edquot() {
// Test the get_os_error_hint function directly for EDQUOT (122)
let hint = get_os_error_hint(122);
assert!(hint.contains("EDQUOT"));
assert!(hint.contains("quota"));
}
#[test]
fn test_get_os_error_hint_unknown_code() {
// Unknown error codes should return empty string
let hint = get_os_error_hint(9999);
assert!(hint.is_empty());
}
#[test]
fn test_os_error_unknown_code_in_enhance() {
// Use an unlikely error code that won't map to a known ErrorKind
let error = Error::from_raw_os_error(9999);
let path = Path::new("/data/file.txt");
let message = enhance_io_error(&error, FileOperation::WriteFile, path);
// Should not have a "Possible cause" hint for unknown OS errors
assert!(!message.contains("Possible cause"));
}
// ==================== IoResultExt Trait Tests ====================
#[test]
fn test_io_result_ext_ok() {
let result: std::io::Result<i32> = Ok(42);
let path = Path::new("/some/path");
let converted = result.with_file_context(FileOperation::ReadFile, path);
assert!(converted.is_ok());
assert_eq!(converted.unwrap(), 42);
}
#[test]
fn test_io_result_ext_err() {
let result: std::io::Result<i32> =
Err(Error::new(ErrorKind::PermissionDenied, "Permission denied"));
let path = Path::new("/protected/file.txt");
let converted = result.with_file_context(FileOperation::ReadFile, path);
assert!(converted.is_err());
let err_string = converted.unwrap_err().to_string();
assert!(err_string.contains("Failed to read file"));
assert!(err_string.contains("/protected/file.txt"));
}
#[test]
fn test_io_result_ext_preserves_error_chain() {
let result: std::io::Result<i32> =
Err(Error::new(ErrorKind::NotFound, "No such file"));
let path = Path::new("/missing/file.txt");
let converted = result.with_file_context(FileOperation::ReadFile, path);
let err = converted.unwrap_err();
// The error chain should contain both the enhanced message and the original error
let err_string = format!("{:?}", err);
assert!(err_string.contains("No such file"));
assert!(err_string.contains("Failed to read file"));
}
// ==================== FileOperation Debug/Clone Tests ====================
#[test]
fn test_file_operation_debug() {
assert_eq!(format!("{:?}", FileOperation::CreateDir), "CreateDir");
assert_eq!(format!("{:?}", FileOperation::ReadFile), "ReadFile");
}
#[test]
fn test_file_operation_clone() {
let op = FileOperation::WriteFile;
let cloned = op;
assert_eq!(format!("{}", op), format!("{}", cloned));
}
#[test]
fn test_file_operation_copy() {
let op1 = FileOperation::DeleteDir;
let op2 = op1; // Copy
assert_eq!(format!("{}", op1), format!("{}", op2));
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod c_api;
mod cache;
mod config;
mod events;
mod file_errors;
mod logging;
mod network;
mod time;
+7 -4
View File
@@ -1,7 +1,7 @@
// This file's job is to deal with the update_server and network side
// of the updater library.
use anyhow::{bail, Context};
use anyhow::bail;
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::Write;
@@ -10,6 +10,7 @@ use std::string::ToString;
use crate::config::{current_arch, current_platform, UpdateConfig};
use crate::events::PatchEvent;
use crate::file_errors::{FileOperation, IoResultExt};
pub fn patches_check_url(base_url: &str) -> String {
format!("{base_url}/api/v1/patches/check")
@@ -234,12 +235,14 @@ pub fn download_to_path(
if let Some(parent) = path.parent() {
shorebird_debug!("Creating download directory: {:?}", parent);
std::fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all failed for {}", parent.display()))?;
.with_file_context(FileOperation::CreateDir, parent)?;
}
shorebird_info!("Writing patch to: {:?}", path);
let mut file = File::create(path)?;
file.write_all(&bytes)?;
let mut file = File::create(path)
.with_file_context(FileOperation::CreateFile, path)?;
file.write_all(&bytes)
.with_file_context(FileOperation::WriteFile, path)?;
shorebird_info!("Wrote patch to: {:?}", path);
Ok(())
}
+14 -7
View File
@@ -6,6 +6,7 @@ use std::io::{Cursor, Read, Seek};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use crate::file_errors::{FileOperation, IoResultExt};
use dyn_clone::DynClone;
use crate::cache::{PatchInfo, UpdaterState};
@@ -297,9 +298,11 @@ fn check_hash(path: &Path, expected_string: &str) -> anyhow::Result<()> {
// Based on guidance from:
// <https://github.com/RustCrypto/hashes#hashing-readable-objects>
let mut file = fs::File::open(path)?;
let mut file = fs::File::open(path)
.with_file_context(FileOperation::ReadFile, path)?;
let mut hasher = Sha256::new();
std::io::copy(&mut file, &mut hasher)?;
std::io::copy(&mut file, &mut hasher)
.with_file_context(FileOperation::ReadFile, path)?;
// Check that the length from copy is the same as the file size?
let hash = hasher.finalize();
let hash_matches = hash.as_slice() == expected;
@@ -339,7 +342,8 @@ fn patch_base(config: &UpdateConfig) -> anyhow::Result<Box<dyn ReadSeek>> {
#[cfg(all(not(test), not(target_os = "ios"), not(target_os = "android")))]
fn patch_base(config: &UpdateConfig) -> anyhow::Result<Box<dyn ReadSeek>> {
let file = fs::File::open(&config.libapp_path)?;
let file = fs::File::open(&config.libapp_path)
.with_file_context(FileOperation::ReadFile, &config.libapp_path)?;
Ok(Box::new(file))
}
@@ -520,9 +524,10 @@ where
shorebird_info!("Inflating patch from {:?}", patch_path);
let compressed_patch_r = BufReader::new(
fs::File::open(patch_path)
.context(format!("Failed to open patch file: {:?}", patch_path))?,
.with_file_context(FileOperation::ReadFile, patch_path)?,
);
let output_file_w = fs::File::create(output_path)?;
let output_file_w = fs::File::create(output_path)
.with_file_context(FileOperation::CreateFile, output_path)?;
// Set up a pipe to connect the writing from the decompression thread
// to the reading of the decompressed patch data on this thread.
@@ -542,11 +547,13 @@ where
});
// Do the patch, using the uncompressed patch data from the pipe.
let mut fresh_r = bipatch::Reader::new(patch_r, base_r)?;
let mut fresh_r = bipatch::Reader::new(patch_r, base_r)
.context("Failed to initialize patch reader")?;
// Write out the resulting patched file to the new location.
let mut output_w = BufWriter::new(output_file_w);
std::io::copy(&mut fresh_r, &mut output_w)?;
std::io::copy(&mut fresh_r, &mut output_w)
.with_file_context(FileOperation::WriteFile, output_path)?;
shorebird_info!("Patch successfully applied to {:?}", output_path);
Ok(())
}