|
| 1 | +use core::ffi::c_int; |
| 2 | + |
| 3 | +use alloc::format; |
| 4 | +use alloc::string::String; |
| 5 | + |
| 6 | +use crate::create_sqlite_optional_text_fn; |
| 7 | +use crate::error::{PSResult, SQLiteError}; |
| 8 | +use sqlite_nostd::{self as sqlite, ColumnType, Value}; |
| 9 | +use sqlite_nostd::{Connection, Context, ResultCode}; |
| 10 | + |
| 11 | +use crate::ext::SafeManagedStmt; |
| 12 | +use crate::util::quote_identifier; |
| 13 | + |
| 14 | +// Apply a data migration to fix any existing data affected by the issue |
| 15 | +// fixed in v0.3.5. |
| 16 | +// |
| 17 | +// The issue was that the `ps_updated_rows` table was not being populated |
| 18 | +// with remove operations in some cases. This causes the rows to be removed |
| 19 | +// from ps_oplog, but not from the ps_data__tables, resulting in dangling rows. |
| 20 | +// |
| 21 | +// The fix here is to find these dangling rows, and add them to ps_updated_rows. |
| 22 | +// The next time the sync_local operation is run, these rows will be removed. |
| 23 | +pub fn apply_v035_fix(db: *mut sqlite::sqlite3) -> Result<i64, SQLiteError> { |
| 24 | + // language=SQLite |
| 25 | + let statement = db |
| 26 | + .prepare_v2("SELECT name, powersync_external_table_name(name) FROM sqlite_master WHERE type='table' AND name GLOB 'ps_data__*'") |
| 27 | + .into_db_result(db)?; |
| 28 | + |
| 29 | + while statement.step()? == ResultCode::ROW { |
| 30 | + let full_name = statement.column_text(0)?; |
| 31 | + let short_name = statement.column_text(1)?; |
| 32 | + let quoted = quote_identifier(full_name); |
| 33 | + |
| 34 | + // language=SQLite |
| 35 | + let statement = db.prepare_v2(&format!( |
| 36 | + " |
| 37 | +INSERT OR IGNORE INTO ps_updated_rows(row_type, row_id) |
| 38 | +SELECT ?1, id FROM {} |
| 39 | + WHERE NOT EXISTS ( |
| 40 | + SELECT 1 FROM ps_oplog |
| 41 | + WHERE row_type = ?1 AND row_id = {}.id |
| 42 | + );", |
| 43 | + quoted, quoted |
| 44 | + ))?; |
| 45 | + statement.bind_text(1, short_name, sqlite::Destructor::STATIC)?; |
| 46 | + |
| 47 | + statement.exec()?; |
| 48 | + } |
| 49 | + |
| 50 | + Ok(1) |
| 51 | +} |
| 52 | + |
| 53 | +/// Older versions of the JavaScript SDK for PowerSync used to encode the subkey in oplog data |
| 54 | +/// entries as JSON. |
| 55 | +/// |
| 56 | +/// It wasn't supposed to do that, since the keys are regular strings already. To make databases |
| 57 | +/// created with those SDKs compatible with other SDKs or the sync client implemented in the core |
| 58 | +/// extensions, a migration is necessary. Since this migration is only relevant for the JS SDK, it |
| 59 | +/// is mostly implemented there. However, the helper function to remove the key encoding is |
| 60 | +/// implemented here because user-defined functions are expensive on JavaScript. |
| 61 | +fn remove_duplicate_key_encoding(key: &str) -> Option<String> { |
| 62 | + // Acceptable format: <type>/<id>/<subkey> |
| 63 | + // Inacceptable format: <type>/<id>/"<subkey>" |
| 64 | + // This is a bit of a tricky conversion because both type and id can contain slashes and quotes. |
| 65 | + // However, the subkey is either a UUID value or a `<table>/UUID` value - so we know it can't |
| 66 | + // end in a quote unless the improper encoding was used. |
| 67 | + if !key.ends_with('"') { |
| 68 | + return None; |
| 69 | + } |
| 70 | + |
| 71 | + // Since the subkey is JSON-encoded, find the start quote by going backwards. |
| 72 | + let mut chars = key.char_indices(); |
| 73 | + chars.next_back()?; // Skip the quote ending the string |
| 74 | + |
| 75 | + enum FoundStartingQuote { |
| 76 | + HasQuote { index: usize }, |
| 77 | + HasBackslachThenQuote { quote_index: usize }, |
| 78 | + } |
| 79 | + let mut state: Option<FoundStartingQuote> = None; |
| 80 | + let found_starting_quote = loop { |
| 81 | + if let Some((i, char)) = chars.next_back() { |
| 82 | + state = match state { |
| 83 | + Some(FoundStartingQuote::HasQuote { index }) => { |
| 84 | + if char == '\\' { |
| 85 | + // We've seen a \" pattern, not the start of the string |
| 86 | + Some(FoundStartingQuote::HasBackslachThenQuote { quote_index: index }) |
| 87 | + } else { |
| 88 | + break Some(index); |
| 89 | + } |
| 90 | + } |
| 91 | + Some(FoundStartingQuote::HasBackslachThenQuote { quote_index }) => { |
| 92 | + if char == '\\' { |
| 93 | + // \\" pattern, the quote is unescaped |
| 94 | + break Some(quote_index); |
| 95 | + } else { |
| 96 | + None |
| 97 | + } |
| 98 | + } |
| 99 | + None => { |
| 100 | + if char == '"' { |
| 101 | + Some(FoundStartingQuote::HasQuote { index: i }) |
| 102 | + } else { |
| 103 | + None |
| 104 | + } |
| 105 | + } |
| 106 | + } |
| 107 | + } else { |
| 108 | + break None; |
| 109 | + } |
| 110 | + }?; |
| 111 | + |
| 112 | + let before_json = &key[..found_starting_quote]; |
| 113 | + let mut result: String = serde_json::from_str(&key[found_starting_quote..]).ok()?; |
| 114 | + |
| 115 | + result.insert_str(0, before_json); |
| 116 | + Some(result) |
| 117 | +} |
| 118 | + |
| 119 | +fn powersync_remove_duplicate_key_encoding_impl( |
| 120 | + ctx: *mut sqlite::context, |
| 121 | + args: &[*mut sqlite::value], |
| 122 | +) -> Result<Option<String>, SQLiteError> { |
| 123 | + let arg = args.get(0).ok_or(ResultCode::MISUSE)?; |
| 124 | + |
| 125 | + if arg.value_type() != ColumnType::Text { |
| 126 | + return Err(ResultCode::MISMATCH.into()); |
| 127 | + } |
| 128 | + |
| 129 | + return Ok(remove_duplicate_key_encoding(arg.text())); |
| 130 | +} |
| 131 | + |
| 132 | +create_sqlite_optional_text_fn!( |
| 133 | + powersync_remote_duplicate_key_encoding, |
| 134 | + powersync_remove_duplicate_key_encoding_impl, |
| 135 | + "powersync_remote_duplicate_key_encoding" |
| 136 | +); |
| 137 | + |
| 138 | +pub fn register(db: *mut sqlite::sqlite3) -> Result<(), ResultCode> { |
| 139 | + db.create_function_v2( |
| 140 | + "powersync_remote_duplicate_key_encoding", |
| 141 | + 1, |
| 142 | + sqlite::UTF8 | sqlite::DETERMINISTIC, |
| 143 | + None, |
| 144 | + Some(powersync_remote_duplicate_key_encoding), |
| 145 | + None, |
| 146 | + None, |
| 147 | + None, |
| 148 | + )?; |
| 149 | + Ok(()) |
| 150 | +} |
| 151 | + |
| 152 | +#[cfg(test)] |
| 153 | +mod test { |
| 154 | + use core::assert_matches::assert_matches; |
| 155 | + |
| 156 | + use super::remove_duplicate_key_encoding; |
| 157 | + |
| 158 | + fn assert_unaffected(source: &str) { |
| 159 | + assert_matches!(remove_duplicate_key_encoding(source), None); |
| 160 | + } |
| 161 | + |
| 162 | + #[test] |
| 163 | + fn does_not_change_unaffected_keys() { |
| 164 | + assert_unaffected("object_type/object_id/subkey"); |
| 165 | + assert_unaffected("object_type/object_id/null"); |
| 166 | + |
| 167 | + // Object type and ID could technically contain quotes and forward slashes |
| 168 | + assert_unaffected(r#""object"/"type"/subkey"#); |
| 169 | + assert_unaffected("object\"/type/object\"/id/subkey"); |
| 170 | + |
| 171 | + // Invalid key, but we shouldn't crash |
| 172 | + assert_unaffected("\"key\""); |
| 173 | + } |
| 174 | + |
| 175 | + #[test] |
| 176 | + fn removes_quotes() { |
| 177 | + assert_eq!( |
| 178 | + remove_duplicate_key_encoding("foo/bar/\"baz\"").unwrap(), |
| 179 | + "foo/bar/baz", |
| 180 | + ); |
| 181 | + |
| 182 | + assert_eq!( |
| 183 | + remove_duplicate_key_encoding(r#"foo/bar/"nested/subkey""#).unwrap(), |
| 184 | + "foo/bar/nested/subkey" |
| 185 | + ); |
| 186 | + |
| 187 | + assert_eq!( |
| 188 | + remove_duplicate_key_encoding(r#"foo/bar/"escaped\"key""#).unwrap(), |
| 189 | + "foo/bar/escaped\"key" |
| 190 | + ); |
| 191 | + assert_eq!( |
| 192 | + remove_duplicate_key_encoding(r#"foo/bar/"escaped\\key""#).unwrap(), |
| 193 | + "foo/bar/escaped\\key" |
| 194 | + ); |
| 195 | + assert_eq!( |
| 196 | + remove_duplicate_key_encoding(r#"foo/bar/"/\\"subkey""#).unwrap(), |
| 197 | + "foo/bar/\"/\\\\subkey" |
| 198 | + ); |
| 199 | + } |
| 200 | +} |
0 commit comments