backupVerifier.mjs 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. // @ts-check
  2. import OError from '@overleaf/o-error'
  3. import chunkStore from '../lib/chunk_store/index.js'
  4. import {
  5. backupPersistor,
  6. chunksBucket,
  7. projectBlobsBucket,
  8. } from './backupPersistor.mjs'
  9. import { Blob, Chunk, History } from 'overleaf-editor-core'
  10. import { BlobStore, GLOBAL_BLOBS, makeProjectKey } from './blob_store/index.js'
  11. import blobHash from './blob_hash.js'
  12. import { NotFoundError } from '@overleaf/object-persistor/src/Errors.js'
  13. import logger from '@overleaf/logger'
  14. import path from 'node:path'
  15. import projectKey from '@overleaf/object-persistor/src/ProjectKey.js'
  16. import streams from './streams.js'
  17. import objectPersistor from '@overleaf/object-persistor'
  18. import { getEndDateForRPO } from '../../backupVerifier/utils.mjs'
  19. /**
  20. * @typedef {import("@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor.js").CachedPerProjectEncryptedS3Persistor} CachedPerProjectEncryptedS3Persistor
  21. */
  22. /**
  23. * @param {string} historyId
  24. * @param {string} hash
  25. */
  26. export async function verifyBlob(historyId, hash) {
  27. return await verifyBlobs(historyId, [hash])
  28. }
  29. /**
  30. *
  31. * @param {string} historyId
  32. * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
  33. */
  34. async function getProjectPersistor(historyId) {
  35. try {
  36. return await backupPersistor.forProjectRO(
  37. projectBlobsBucket,
  38. makeProjectKey(historyId, '')
  39. )
  40. } catch (err) {
  41. if (err instanceof NotFoundError) {
  42. throw new BackupCorruptedError('dek does not exist', {}, err)
  43. }
  44. throw err
  45. }
  46. }
  47. /**
  48. * @param {string} historyId
  49. * @param {Array<string>} hashes
  50. * @param {CachedPerProjectEncryptedS3Persistor} [projectCache]
  51. */
  52. export async function verifyBlobs(historyId, hashes, projectCache) {
  53. if (hashes.length === 0) throw new Error('bug: empty hashes')
  54. if (!projectCache) {
  55. projectCache = await getProjectPersistor(historyId)
  56. }
  57. const blobStore = new BlobStore(historyId)
  58. for (const hash of hashes) {
  59. const path = makeProjectKey(historyId, hash)
  60. const blob = await blobStore.getBlob(hash)
  61. if (!blob) throw new Blob.NotFoundError(hash)
  62. let stream
  63. try {
  64. stream = await projectCache.getObjectStream(projectBlobsBucket, path, {
  65. autoGunzip: true,
  66. })
  67. } catch (err) {
  68. if (err instanceof NotFoundError) {
  69. throw new BackupCorruptedMissingBlobError('missing blob', {
  70. path,
  71. hash,
  72. })
  73. }
  74. throw err
  75. }
  76. const backupHash = await blobHash.fromStream(blob.getByteLength(), stream)
  77. if (backupHash !== hash) {
  78. throw new BackupCorruptedInvalidBlobError(
  79. 'hash mismatch for backed up blob',
  80. {
  81. path,
  82. hash,
  83. backupHash,
  84. }
  85. )
  86. }
  87. }
  88. }
  89. /**
  90. * @param {string} historyId
  91. * @param {Date} [endTimestamp]
  92. */
  93. export async function verifyProjectWithErrorContext(
  94. historyId,
  95. endTimestamp = getEndDateForRPO()
  96. ) {
  97. try {
  98. await verifyProject(historyId, endTimestamp)
  99. } catch (err) {
  100. // @ts-ignore err is Error instance
  101. throw OError.tag(err, 'verifyProject', { historyId, endTimestamp })
  102. }
  103. }
  104. /**
  105. *
  106. * @param {string} historyId
  107. * @param {number} startVersion
  108. * @param {CachedPerProjectEncryptedS3Persistor} backupPersistorForProject
  109. * @return {Promise<any>}
  110. */
  111. export async function loadChunk(
  112. historyId,
  113. startVersion,
  114. backupPersistorForProject
  115. ) {
  116. const key = path.join(
  117. projectKey.format(historyId),
  118. projectKey.pad(startVersion)
  119. )
  120. try {
  121. const buf = await streams.gunzipStreamToBuffer(
  122. await backupPersistorForProject.getObjectStream(chunksBucket, key)
  123. )
  124. return JSON.parse(buf.toString('utf-8'))
  125. } catch (err) {
  126. if (err instanceof objectPersistor.Errors.NotFoundError) {
  127. throw new Chunk.NotPersistedError(historyId)
  128. }
  129. if (err instanceof Error) {
  130. throw OError.tag(err, 'Failed to load chunk', { historyId, startVersion })
  131. }
  132. throw err
  133. }
  134. }
  135. /**
  136. * @param {string} historyId
  137. * @param {Date} endTimestamp
  138. */
  139. export async function verifyProject(historyId, endTimestamp) {
  140. const backend = chunkStore.getBackend(historyId)
  141. const [first, last] = await Promise.all([
  142. backend.getChunkForVersion(historyId, 0),
  143. backend.getChunkForTimestamp(historyId, endTimestamp),
  144. ])
  145. const chunksRecordsToVerify = [
  146. {
  147. chunkId: first.id,
  148. chunkLabel: 'first',
  149. ...first,
  150. },
  151. ]
  152. if (first.startVersion !== last.startVersion) {
  153. chunksRecordsToVerify.push({
  154. chunkId: last.id,
  155. chunkLabel: 'last before RPO',
  156. ...last,
  157. })
  158. }
  159. const projectCache = await getProjectPersistor(historyId)
  160. const chunks = await Promise.all(
  161. chunksRecordsToVerify.map(async chunk => {
  162. try {
  163. const chunkContents = await loadChunk(
  164. historyId,
  165. chunk.startVersion,
  166. projectCache
  167. )
  168. // filter the raw changes to only those that are <= endTimestamp
  169. // to simulate the state of the project at endTimestamp
  170. chunkContents.changes = chunkContents.changes.filter(
  171. change => new Date(change.timestamp) <= endTimestamp
  172. )
  173. return History.fromRaw(chunkContents)
  174. } catch (err) {
  175. if (err instanceof Chunk.NotPersistedError) {
  176. throw new BackupRPOViolationChunkNotBackedUpError(
  177. 'BackupRPOviolation: chunk not backed up',
  178. chunk
  179. )
  180. }
  181. throw err
  182. }
  183. })
  184. )
  185. const seenBlobs = new Set()
  186. const blobsToVerify = []
  187. for (const chunk of chunks) {
  188. /** @type {Set<string>} */
  189. const chunkBlobs = new Set()
  190. chunk.findBlobHashes(chunkBlobs)
  191. let hasAddedBlobFromThisChunk = false
  192. for (const blobHash of chunkBlobs) {
  193. if (seenBlobs.has(blobHash)) continue // old blob
  194. if (GLOBAL_BLOBS.has(blobHash)) continue // global blob
  195. seenBlobs.add(blobHash)
  196. if (!hasAddedBlobFromThisChunk) {
  197. blobsToVerify.push(blobHash)
  198. hasAddedBlobFromThisChunk = true
  199. }
  200. }
  201. }
  202. if (blobsToVerify.length === 0) {
  203. logger.debug(
  204. {
  205. historyId,
  206. chunksRecordsToVerify: chunksRecordsToVerify.map(c => c.chunkId),
  207. },
  208. 'chunks contain no blobs to verify'
  209. )
  210. return
  211. }
  212. await verifyBlobs(historyId, blobsToVerify, projectCache)
  213. }
  214. export class BackupCorruptedError extends OError {}
  215. export class BackupRPOViolationError extends OError {}
  216. export class BackupCorruptedMissingBlobError extends BackupCorruptedError {}
  217. export class BackupCorruptedInvalidBlobError extends BackupCorruptedError {}
  218. export class BackupRPOViolationChunkNotBackedUpError extends OError {}