xtax-blob-storage 0.1.2

Application-level blob storage abstraction with filesystem/S3 backends, optional encryption, rekey, cleanup, and background maintenance.
Documentation
# Error handling

All errors in `xtax-blob-storage` are represented by the `BlobStorageError` enum, which uses `#[derive(thiserror::Error)]` for automatic `Display`, `Error`, and `From` implementations — no manual boilerplate required.

## Error types

### `BlobStorageError` variants

```rust
#[derive(Debug, thiserror::Error)]
pub enum BlobStorageError {
    /// The requested blob was not found.
    #[error("blob not found: {0}")]
    NotFound(String),

    /// A blob with this key already exists.
    #[error("blob already exists: {0}")]
    AlreadyExists(String),

    /// The operation is not supported by this backend.
    #[error("operation not supported: {0}")]
    NotSupported(String),

    /// The backend is misconfigured — for example, the S3 bucket does not
    /// exist, or the FS root directory has been deleted.
    ///
    /// This is distinct from [`Storage`] errors: it indicates
    /// a backend configuration problem, not a transient storage failure.
    #[error("backend misconfigured: {0}")]
    BackendMisconfigured(String),

    /// The provided input is invalid (empty key, path traversal, etc.).
    #[error("invalid input: {0}")]
    InvalidInput(String),

    /// Backend storage error — wraps an underlying cause.
    #[error("storage error: {message}")]
    Storage {
        message: String,
        #[source]
        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
    },

    /// Encryption-layer error.
    #[error("encryption error: {message}")]
    Encryption {
        message: String,
        #[source]
        source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
    },

    /// The caller does not have permission to perform this operation.
    #[error("permission denied: {0}")]
    PermissionDenied(String),

    /// Batch operation partially failed.
    /// Contains details about which keys succeeded and which failed.
    #[error("batch error: {0}")]
    Batch(#[from] BatchError),
}
```

## Batch errors

Batch operations (`put`, `delete`) can partially succeed. When at least one key fails, a `BlobStorageError::Batch` is returned containing a `BatchError`.

### `BatchError`

```rust
pub struct BatchError {
    /// Keys that were processed successfully (including `NotFound` on delete).
    pub succeeded: Vec<String>,
    /// Keys that failed, with per-key error details.
    pub errors: Vec<KeyError>,
}
```

The `BatchError` provides `total_count()` and `failed_count()` helpers so callers can decide whether to retry or report.

### `KeyError`

```rust
pub struct KeyError {
    /// The blob key that failed.
    pub key: String,
    /// The categorised error.
    pub error: PerKeyError,
}
```

### `PerKeyError`

```rust
pub enum PerKeyError {
    /// The key was not found.
    NotFound,
    /// The operation failed due to insufficient permissions.
    PermissionDenied(String),
    /// Any other unexpected error.
    Unknown { message: String },
}
```

## Idempotent delete semantics

`NotFound` on delete is **NOT an error** — it is treated as success.

- **`get()`** and **`get_with_metadata()`**: `NotFound` means the blob does not exist. The backend returns `BlobStorageError::NotFound` directly.
- **`delete()`**: If a key doesn't exist, it is considered successfully processed — the key was already gone. This makes `delete()` idempotent.
- In both FS and S3 backends, a `NotFound` during delete is added to `BatchError::succeeded`, not `BatchError::errors`.

This difference is important when handling batch errors: a `PerKeyError::NotFound` from `delete` means the key was processed successfully, while `BlobStorageError::NotFound` from `get` means the key genuinely doesn't exist.

## `std::error::Error` compatibility

Because `BlobStorageError` derives `thiserror::Error`, it automatically implements `std::error::Error`. This means:

- It works with `Box<dyn std::error::Error>` and `anyhow::Error` for application-level error propagation.
- The `#[source]` fields provide `Error::source()` chaining — the underlying cause is preserved.
- The `#[error]` attributes provide idiomatic `Display` messages without manual `fmt::Display` impls.

`BatchError` also implements `std::error::Error`.

## Result type

```rust
pub type Result<T> = std::result::Result<T, BlobStorageError>;
```

## Matching errors

Because `BlobStorageError` is an enum, you match on variants directly:

```rust
match store.get("nonexistent.txt").await {
    Err(BlobStorageError::NotFound(_)) => {
        // Handle "not found" case
    }
    Err(BlobStorageError::BackendMisconfigured(msg)) => {
        // Handle misconfigured backend — e.g. S3 bucket missing, FS root deleted
        eprintln!("Backend misconfigured: {msg}");
        std::process::exit(1);
    }
    Err(BlobStorageError::Storage { message, .. }) => {
        // Handle storage error
    }
    Err(BlobStorageError::Batch(batch)) => {
        // Handle partial batch failure
        eprintln!("{} keys failed", batch.failed_count());
    }
    Ok(reader) => {
        // Use the reader
    }
}
```

## Backend misconfiguration errors

`BackendMisconfigured` indicates a backend configuration problem, not a transient failure or a genuine "not found" condition:

- **S3 backend**: Returned when the configured S3 bucket does not exist (`NoSuchBucket`). This can be caused by wrong credentials, wrong region, or the bucket being deleted. Previously, this was incorrectly mapped to `NotFound(key)`, which hid the misconfiguration.
- **FS backend**: Returned when the root directory has been deleted from outside the process (e.g., `rm -rf /tmp/my-blobs`). The `FsBlobStore::new()` creates the root on construction, so a missing root indicates external interference.

This variant should typically be treated as **fatal** — the application cannot recover from a misconfigured backend without administrator intervention.


## From conversions

- `From<std::io::Error>` — maps I/O errors to `BlobStorageError::Storage { source: ... }`
- `From<String>` — maps string errors to `BlobStorageError::Storage { source: None }`
- `From<&str>` — maps string slices to `BlobStorageError::Storage { source: None }`
- `From<BatchError>` — maps batch errors to `BlobStorageError::Batch`. The `#[from]` attribute on the `Batch` variant generates this automatically.

## Feature

`thiserror` is the only error-derive dependency. It is always available (no feature gate).