Skip to main content

mkit_server/
storage_error.rs

1//! Storage-failure redaction (issue #794).
2//!
3//! A backend failure (R2, Durable Object, `SQLite` or filesystem error text)
4//! can embed bucket keys, account ids or JS exception text. It goes to the
5//! server-side log only; the client sees one fixed message per
6//! [`StorageOp`] family. The mapping is exhaustive, so a new operation must
7//! be given a public message explicitly.
8//!
9//! The canonical copy of vcs-worker's former `storage_error.rs` (removed in
10//! WP-M0-17), with the operations generalized from R2 and the ref-store
11//! Durable Object to any blob or metadata store. `apps/repo-worker` keeps
12//! its own copy (planner decision Q11).
13
14use std::fmt;
15
16use crate::error::ServerError;
17
18/// A storage-backend operation that can fail. It names what failed in the
19/// server-side log line and picks the client-facing message; the backend's
20/// own error text never reaches the client.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum StorageOp {
24    /// Resolving the blob store (for example an R2 bucket binding) failed.
25    BlobBinding,
26    /// A blob write failed.
27    BlobPut,
28    /// A blob read request failed.
29    BlobGet,
30    /// Reading a blob's body failed, or the body was missing.
31    BlobRead,
32    /// A blob existence check failed.
33    BlobHead,
34    /// Starting or completing a multipart storage session failed.
35    MultipartSession,
36    /// Streaming or committing a multipart part failed.
37    MultipartPart,
38    /// Resolving the metadata store (for example a Durable Object binding)
39    /// failed.
40    MetaBinding,
41    /// Deriving or fetching a metadata-store stub failed.
42    MetaStub,
43    /// Building a metadata-store request failed.
44    MetaRequest,
45    /// The metadata-store call itself failed (a network or runtime error,
46    /// not an error answer from the store).
47    MetaCall,
48    /// Decoding a metadata-store response failed.
49    MetaDecode,
50    /// Serializing an outgoing request body failed. Our own request, so it
51    /// never carries backend detail.
52    RequestSerialize,
53    /// A SQL statement failed.
54    SqlExec,
55    /// A filesystem operation failed.
56    FsIo,
57}
58
59impl StorageOp {
60    /// Every variant, for exhaustive checks.
61    pub const ALL: [Self; 15] = [
62        Self::BlobBinding,
63        Self::BlobPut,
64        Self::BlobGet,
65        Self::BlobRead,
66        Self::BlobHead,
67        Self::MultipartSession,
68        Self::MultipartPart,
69        Self::MetaBinding,
70        Self::MetaStub,
71        Self::MetaRequest,
72        Self::MetaCall,
73        Self::MetaDecode,
74        Self::RequestSerialize,
75        Self::SqlExec,
76        Self::FsIo,
77    ];
78
79    /// Label for the server-side log line only.
80    const fn label(self) -> &'static str {
81        match self {
82            Self::BlobBinding => "blob store binding",
83            Self::BlobPut => "blob put",
84            Self::BlobGet => "blob get",
85            Self::BlobRead => "blob read",
86            Self::BlobHead => "blob head",
87            Self::MultipartSession => "multipart session",
88            Self::MultipartPart => "multipart part",
89            Self::MetaBinding => "metadata store binding",
90            Self::MetaStub => "metadata store stub",
91            Self::MetaRequest => "metadata store request build",
92            Self::MetaCall => "metadata store call",
93            Self::MetaDecode => "metadata store decode",
94            Self::RequestSerialize => "request serialize",
95            Self::SqlExec => "sql exec",
96            Self::FsIo => "filesystem io",
97        }
98    }
99
100    /// The fixed client-facing message for this operation's family. It never
101    /// depends on the underlying error.
102    #[must_use]
103    pub const fn public_message(self) -> &'static str {
104        match self {
105            Self::BlobBinding
106            | Self::BlobPut
107            | Self::BlobGet
108            | Self::BlobRead
109            | Self::BlobHead
110            | Self::MultipartSession
111            | Self::MultipartPart => "object storage request failed",
112            Self::MetaBinding
113            | Self::MetaStub
114            | Self::MetaRequest
115            | Self::MetaCall
116            | Self::MetaDecode
117            | Self::SqlExec => "ref store request failed",
118            Self::RequestSerialize => "internal request encoding failed",
119            Self::FsIo => "storage request failed",
120        }
121    }
122}
123
124/// The server-side log line and the client-facing error for a failed
125/// storage operation.
126///
127/// `detail`, the backend's raw error, appears only in the log line and in
128/// the error's redacted [`ServerError::log_detail`]. The public message is
129/// always [`StorageOp::public_message`], with code
130/// [`crate::Code::Internal`].
131#[must_use]
132pub fn describe_and_map(op: StorageOp, detail: impl fmt::Display) -> (String, ServerError) {
133    let log_line = format!("{}: {detail}", op.label());
134    let err = ServerError::internal(op.public_message(), &log_line);
135    (log_line, err)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::error::Code;
142
143    // Ported from apps/vcs-worker/src/storage_error.rs, with the fourth
144    // message added for `FsIo`.
145    #[test]
146    fn every_op_maps_to_one_of_the_fixed_generic_messages() {
147        const ALLOWED: &[&str] = &[
148            "object storage request failed",
149            "ref store request failed",
150            "internal request encoding failed",
151            "storage request failed",
152        ];
153        for op in StorageOp::ALL {
154            assert!(
155                ALLOWED.contains(&op.public_message()),
156                "{op:?} has an unexpected client message: {}",
157                op.public_message()
158            );
159        }
160    }
161
162    // Ported from apps/vcs-worker/src/storage_error.rs: the regression test
163    // for issue #794.
164    #[test]
165    fn simulated_storage_failure_is_logged_but_never_reaches_the_client() {
166        let raw_detail = "R2Error: bucket 'mkit-prod-packs-9c1e' access denied \
167            for account 4f8e21a9-c3b2-4d11-9e77-1a2b3c4d5e6f \
168            (JsValue: TypeError at fetch_r2_binding@worker.js:1842)";
169
170        for op in StorageOp::ALL {
171            let (log_line, err) = describe_and_map(op, raw_detail);
172            assert!(
173                log_line.contains(raw_detail),
174                "{op:?}: log line dropped the real error detail: {log_line:?}"
175            );
176            assert_eq!(err.code(), Code::Internal);
177            assert_eq!(err.public_message(), op.public_message());
178            assert_eq!(err.log_detail(), Some(log_line.as_str()));
179            let shown = [format!("{err}"), format!("{err:?}"), format!("{err:#?}")];
180            for client in [err.public_message()]
181                .into_iter()
182                .chain(shown.iter().map(String::as_str))
183            {
184                for secret in [
185                    raw_detail,
186                    "mkit-prod-packs-9c1e",
187                    "4f8e21a9-c3b2-4d11-9e77-1a2b3c4d5e6f",
188                    "worker.js",
189                ] {
190                    assert!(
191                        !client.contains(secret),
192                        "{op:?} leaked {secret:?}: {client}"
193                    );
194                }
195            }
196        }
197    }
198}