Skip to content

test: shallow snapshot retains deleted rich-text style values (privacy leak) - #1057

Open
nightscape wants to merge 1 commit into
loro-dev:mainfrom
nightscape:shallow-snapshot-style-leak-test
Open

test: shallow snapshot retains deleted rich-text style values (privacy leak)#1057
nightscape wants to merge 1 commit into
loro-dev:mainfrom
nightscape:shallow-snapshot-style-leak-test

Conversation

@nightscape

@nightscape nightscape commented Jul 30, 2026

Copy link
Copy Markdown

This PR contains only a failing test. I can try to come up with a fix, but want to leave that up to you.

Summary

A shallow-snapshot export retains the value of a rich-text style op whose entire marked range has been deleted. Deleted character content is correctly dropped from the same export, so this is an asymmetry rather than "shallow snapshots keep history".

Minimal repro (single peer, no concurrency needed):

let text = doc.get_text("text");
text.insert(0, "SECRET-TEXT-a7b2")?;
text.mark(0..16, "comment", LoroValue::String("SECRET-STYLE-VALUE-e5f1".into()))?;
doc.commit();
text.delete(0, 16)?;                  // whole marked range gone
doc.commit();

let shallow = doc.export(ExportMode::shallow_snapshot(&doc.oplog_frontiers()))?;
// "SECRET-TEXT-a7b2"        -> absent   (correct)
// "SECRET-STYLE-VALUE-e5f1" -> PRESENT  (the bug)

Every read API says the data is gone: text.to_string() is "", and neither secret appears in get_deep_value(). Only the raw exported bytes still carry the style value. It also survives import into a fresh doc followed by a re-export, so it cannot be laundered by round-tripping.

The shallow-snapshots concepts page has a "Content Redaction" section that presents this exact export as the way to sanitize a document (https://loro.dev/docs/concepts/shallow_snapshots, source: loro-dev/loro-docs pages/docs/concepts/shallow_snapshots.mdx). Its example is structurally identical to the repro:

const redacted = doc.export({
  mode: "shallow-snapshot",
  frontiers: doc.frontiers(),
});
// Sensitive data permanently removed from history

That promise holds for the plain-text case the docs show, and breaks the moment the secret is a mark value instead of characters.

So the two documented sanitization routes disagree about whether a dead style value is sensitive.

Suspected mechanism

calc_shallow_doc_start (crates/loro-internal/src/encoding/shallow_snapshot.rs:318) walks a multi-head frontier back to the LCA, and the retained op tail from that start is shipped unredacted. Ops at the start frontier are always re-included, and a start landing on a StyleStart is advanced so the Start/End pair stays together — which keeps the mark op, and with it its value, in the export. In the single-peer repro above the start is already the tip, and the style value still ships, so the retained-tail encoding path looks like the place to fix rather than the frontier calculation itself.

With a two-peer concurrent frontier (2 heads), the LCA regression is much broader. In my probing the deleted character content leaked too, because history gets retained back to the common ancestor. That is arguably expected given the frontier can't be trimmed to a multi-head point, but it means "export at current frontiers to sanitize" is unsafe in exactly the collaborative case.

Red test output

running 1 test
The application panicked (crashed).
Message:  style value of a fully deleted range must not survive a shallow snapshot
Location: crates/loro/tests/integration_test/shallow_snapshot_test.rs:562

test integration_test::shallow_snapshot_test::shallow_snapshot_drops_deleted_text_but_retains_dead_style_values ... FAILED

failures:
    integration_test::shallow_snapshot_test::shallow_snapshot_drops_deleted_text_but_retains_dead_style_values

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 178 filtered out; finished in 0.00s

The failure is on the style assertion. The deleted-character control assertion immediately above it passes, which is the point: the export does redact character content, just not style values.

Context

I hit this in production: a local-first app that prunes document history before sharing a document with another peer. The pruning uses exactly the documented shallow_snapshot(doc.frontiers()) flow, and mark values on deleted text are user-authored comment/annotation content — so the shared bytes carried data the UI had already shown as deleted.

Exporting a shallow snapshot at the current frontiers is documented as a
content-redaction mechanism. Deleted character content is correctly dropped
from the export, but the value of a rich-text style op whose entire range has
been deleted is still shipped verbatim, and survives import + re-export.

The test asserts both, so the passing character control documents the
asymmetry; the style assertion is red today.
@nightscape
nightscape marked this pull request as ready for review July 30, 2026 12:10
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