Add a recovery path for autosaved documents that don't open successfully#4157
Add a recovery path for autosaved documents that don't open successfully#4157Keavon wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a recovery mechanism for autosaved documents that fail to deserialize during startup. It adds new dialogs for reporting these failures and allows users to download the raw serialized content—either as a ZIP file on the web or as individual files within a folder on desktop. The implementation includes eager loading of all autosaves to detect issues early and updates to the persistence layer to prevent failed documents from being garbage collected. Review feedback highlights a potential directory traversal vulnerability when using document names as filenames and suggests providing fallback names for untitled documents in the failure list to improve clarity.
| .failed_to_load_documents | ||
| .values() | ||
| .map(|(info, content)| { | ||
| let stem = if info.name.trim().is_empty() { format!("document-{:x}", info.id.0) } else { info.name.clone() }; |
There was a problem hiding this comment.
The document name is used to construct the filename for recovery. If the name contains path separators (e.g., / or ), it could lead to directory traversal or unexpected file placement when the desktop wrapper writes these files to disk. Sanitizing the name by replacing separators ensures that all recovered files stay within the intended recovery folder.
| let stem = if info.name.trim().is_empty() { format!("document-{:x}", info.id.0) } else { info.name.clone() }; | |
| let stem = if info.name.trim().is_empty() { format!("document-{:x}", info.id.0) } else { info.name.replace(['/', '\\'], "_") }; |
| if self.failed_to_load_documents.is_empty() { | ||
| return; | ||
| } | ||
| let failed_document_names = self.failed_to_load_documents.values().map(|(info, _)| info.name.clone()).collect(); |
There was a problem hiding this comment.
When a document name is empty, it appears as an empty bullet point in the failure dialog. Providing a fallback name (e.g., including the document ID) makes the list items identifiable and improves the user experience.
| let failed_document_names = self.failed_to_load_documents.values().map(|(info, _)| info.name.clone()).collect(); | |
| let failed_document_names = self.failed_to_load_documents.values().map(|(info, _)| if info.name.trim().is_empty() { format!("Untitled Document ({:x})", info.id.0) } else { info.name.clone() }).collect(); |
There was a problem hiding this comment.
3 issues found across 14 files
Confidence score: 2/5
- There is a high-confidence, high-severity path-handling risk in
desktop/wrapper/src/messages.rs: recovered document names are transported as rawStrings, so crafted names could escape the chosen recovery directory when later joined into paths. - A related issue in
editor/src/messages/portfolio/portfolio_message_handler.rsalso passes unsanitized names into ZIP/folder outputs, so path separators or reserved characters can produce unsafe or invalid filenames for users. - The empty-name rendering bug in
editor/src/messages/portfolio/portfolio_message_handler.rsis low severity (UI clarity), but it reinforces that filename normalization is currently incomplete across the recovery flow. - Pay close attention to
desktop/wrapper/src/messages.rsandeditor/src/messages/portfolio/portfolio_message_handler.rs- recovery filename sanitization is needed to prevent path traversal/invalid output and improve reliability.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="desktop/wrapper/src/messages.rs">
<violation number="1" location="desktop/wrapper/src/messages.rs:125">
P1: Validate or sanitize recovered document filenames before transporting them as raw `String`s. As written, a crafted document name can escape the selected recovery folder when the desktop wrapper later does `folder.join(filename)`.</violation>
</file>
<file name="editor/src/messages/portfolio/portfolio_message_handler.rs">
<violation number="1" location="editor/src/messages/portfolio/portfolio_message_handler.rs:544">
P3: Empty document names will render as blank bullet points ("• ") in the failure dialog, making the list items unidentifiable. Provide a fallback like `format!("Untitled Document ({:x})", info.id.0)` when the name is empty.</violation>
<violation number="2" location="editor/src/messages/portfolio/portfolio_message_handler.rs:570">
P2: Sanitize recovered document names before turning them into filenames; path separators and reserved characters currently flow straight into the ZIP/folder output.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Re-trigger cubic
| /// The chosen `path` returned by the save dialog has its extension stripped to become the folder name. | ||
| /// The parent directories are created on first write. | ||
| RecoveredDocuments { | ||
| files: Vec<(String, Vec<u8>)>, |
There was a problem hiding this comment.
P1: Validate or sanitize recovered document filenames before transporting them as raw Strings. As written, a crafted document name can escape the selected recovery folder when the desktop wrapper later does folder.join(filename).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At desktop/wrapper/src/messages.rs, line 125:
<comment>Validate or sanitize recovered document filenames before transporting them as raw `String`s. As written, a crafted document name can escape the selected recovery folder when the desktop wrapper later does `folder.join(filename)`.</comment>
<file context>
@@ -111,8 +111,19 @@ pub enum OpenFileDialogContext {
+ /// The chosen `path` returned by the save dialog has its extension stripped to become the folder name.
+ /// The parent directories are created on first write.
+ RecoveredDocuments {
+ files: Vec<(String, Vec<u8>)>,
+ },
}
</file context>
| .failed_to_load_documents | ||
| .values() | ||
| .map(|(info, content)| { | ||
| let stem = if info.name.trim().is_empty() { format!("document-{:x}", info.id.0) } else { info.name.clone() }; |
There was a problem hiding this comment.
P2: Sanitize recovered document names before turning them into filenames; path separators and reserved characters currently flow straight into the ZIP/folder output.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/messages/portfolio/portfolio_message_handler.rs, line 570:
<comment>Sanitize recovered document names before turning them into filenames; path separators and reserved characters currently flow straight into the ZIP/folder output.</comment>
<file context>
@@ -501,7 +531,100 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
+ .failed_to_load_documents
+ .values()
+ .map(|(info, content)| {
+ let stem = if info.name.trim().is_empty() { format!("document-{:x}", info.id.0) } else { info.name.clone() };
+ let base = format!("{stem}.{FILE_EXTENSION}");
+ let unique = match used_names.get(&base).copied() {
</file context>
| let stem = if info.name.trim().is_empty() { format!("document-{:x}", info.id.0) } else { info.name.clone() }; | |
| let stem: String = if info.name.trim().is_empty() { | |
| format!("document-{:x}", info.id.0) | |
| } else { | |
| info.name | |
| .chars() | |
| .map(|c| if matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { '_' } else { c }) | |
| .collect() | |
| }; |
| if self.failed_to_load_documents.is_empty() { | ||
| return; | ||
| } | ||
| let failed_document_names = self.failed_to_load_documents.values().map(|(info, _)| info.name.clone()).collect(); |
There was a problem hiding this comment.
P3: Empty document names will render as blank bullet points ("• ") in the failure dialog, making the list items unidentifiable. Provide a fallback like format!("Untitled Document ({:x})", info.id.0) when the name is empty.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At editor/src/messages/portfolio/portfolio_message_handler.rs, line 544:
<comment>Empty document names will render as blank bullet points ("• ") in the failure dialog, making the list items unidentifiable. Provide a fallback like `format!("Untitled Document ({:x})", info.id.0)` when the name is empty.</comment>
<file context>
@@ -501,7 +531,100 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
+ if self.failed_to_load_documents.is_empty() {
+ return;
+ }
+ let failed_document_names = self.failed_to_load_documents.values().map(|(info, _)| info.name.clone()).collect();
+ let dialog = simple_dialogs::FailedToLoadDocumentsDialog { failed_document_names };
+ dialog.send_dialog_to_frontend(responses);
</file context>
| let failed_document_names = self.failed_to_load_documents.values().map(|(info, _)| info.name.clone()).collect(); | |
| let failed_document_names = self.failed_to_load_documents.values().map(|(info, _)| if info.name.trim().is_empty() { format!("Untitled Document ({:x})", info.id.0) } else { info.name.clone() }).collect(); |
Uh oh!
There was an error while loading. Please reload this page.