Skip to content

Add a recovery path for autosaved documents that don't open successfully#4157

Open
Keavon wants to merge 1 commit into
masterfrom
autosave-recovery
Open

Add a recovery path for autosaved documents that don't open successfully#4157
Keavon wants to merge 1 commit into
masterfrom
autosave-recovery

Conversation

@Keavon
Copy link
Copy Markdown
Member

@Keavon Keavon commented May 17, 2026

image

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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.

Suggested change
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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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();

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 raw Strings, 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.rs also 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.rs is 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.rs and editor/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>)>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant