ArchiveManager.mjs 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. import logger from '@overleaf/logger'
  2. import OError from '@overleaf/o-error'
  3. import metrics from '@overleaf/metrics'
  4. import fs from 'node:fs'
  5. import fsPromises from 'node:fs/promises'
  6. import Path from 'node:path'
  7. import { pipeline } from 'node:stream'
  8. import { callbackify } from 'node:util'
  9. import yauzl from 'yauzl'
  10. import Settings from '@overleaf/settings'
  11. import {
  12. InvalidZipFileError,
  13. EmptyZipFileError,
  14. ZipContentsTooLargeError,
  15. } from './ArchiveErrors.mjs'
  16. const ONE_MEG = 1024 * 1024
  17. /**
  18. * Check if a zip entry's file path is safe and return the destination path.
  19. * Returns null for directory entries.
  20. * Throws for relative or unnormalized paths.
  21. */
  22. function _checkFilePath(entry, destination) {
  23. // transform backslashes to forwardslashes to accommodate badly-behaved zip archives
  24. const transformedFilename = entry.fileName.replace(/\\/g, '/')
  25. // check if the entry is a directory
  26. if (/\/$/.test(transformedFilename)) {
  27. return null
  28. }
  29. // check that the file does not use a relative path
  30. for (const dir of transformedFilename.split('/')) {
  31. if (dir === '..') {
  32. throw new Error('relative path')
  33. }
  34. }
  35. // check that the destination file path is normalized
  36. const dest = `${destination}/${transformedFilename}`
  37. if (dest !== Path.normalize(dest)) {
  38. throw new Error('unnormalized path')
  39. }
  40. return dest
  41. }
  42. // Kept callback-based: called from event handler in _extractZipFiles
  43. function _writeFileEntry(zipfile, entry, destFile, callback) {
  44. fs.mkdir(Path.dirname(destFile), { recursive: true }, err => {
  45. if (err) return callback(err)
  46. zipfile.openReadStream(entry, (err, readStream) => {
  47. if (err) return callback(err)
  48. const writeStream = fs.createWriteStream(destFile)
  49. pipeline(readStream, writeStream, callback)
  50. })
  51. })
  52. }
  53. function _isZipTooLarge(source) {
  54. return new Promise((resolve, reject) => {
  55. let settled = false
  56. const done = (err, result) => {
  57. if (settled) return
  58. settled = true
  59. if (err) reject(err)
  60. else resolve(result)
  61. }
  62. yauzl.open(source, { lazyEntries: true }, (err, zipfile) => {
  63. if (err) {
  64. return done(new InvalidZipFileError().withCause(err))
  65. }
  66. if (
  67. Settings.maxEntitiesPerProject != null &&
  68. zipfile.entryCount > Settings.maxEntitiesPerProject
  69. ) {
  70. zipfile.close()
  71. return done(null, true) // too many files in zip file
  72. }
  73. let totalSizeInBytes = null
  74. zipfile.on('error', err => done(err))
  75. // read all the entries
  76. zipfile.readEntry()
  77. zipfile.on('entry', entry => {
  78. totalSizeInBytes += entry.uncompressedSize
  79. zipfile.readEntry()
  80. })
  81. // no more entries to read
  82. zipfile.on('end', () => {
  83. if (totalSizeInBytes == null || isNaN(totalSizeInBytes)) {
  84. logger.warn(
  85. { source, totalSizeInBytes },
  86. 'error getting bytes of zip'
  87. )
  88. return done(new InvalidZipFileError({ info: { totalSizeInBytes } }))
  89. }
  90. done(null, totalSizeInBytes > ONE_MEG * 300)
  91. })
  92. })
  93. })
  94. }
  95. function _extractZipFiles(source, destination) {
  96. return new Promise((resolve, reject) => {
  97. let settled = false
  98. const done = err => {
  99. if (settled) return
  100. settled = true
  101. if (err) reject(err)
  102. else resolve()
  103. }
  104. yauzl.open(source, { lazyEntries: true }, (err, zipfile) => {
  105. if (err) return done(err)
  106. zipfile.on('error', err => done(err))
  107. // read all the entries
  108. zipfile.readEntry()
  109. let entryFileCount = 0
  110. zipfile.on('entry', entry => {
  111. let destFile
  112. try {
  113. destFile = _checkFilePath(entry, destination)
  114. } catch (err) {
  115. logger.warn({ err, source, destination }, 'skipping bad file path')
  116. zipfile.readEntry() // bad path, just skip to the next file
  117. return
  118. }
  119. if (destFile) {
  120. // only write files
  121. _writeFileEntry(zipfile, entry, destFile, err => {
  122. if (err) {
  123. OError.tag(err, 'error unzipping file entry', {
  124. source,
  125. destFile,
  126. })
  127. zipfile.close() // bail out, stop reading file entries
  128. done(new InvalidZipFileError().withCause(err))
  129. } else {
  130. entryFileCount++
  131. zipfile.readEntry() // continue to the next file
  132. }
  133. })
  134. } else {
  135. // if it's a directory, continue
  136. zipfile.readEntry()
  137. }
  138. })
  139. // no more entries to read
  140. zipfile.on('end', () => {
  141. if (entryFileCount > 0) {
  142. done()
  143. } else {
  144. done(new EmptyZipFileError())
  145. }
  146. })
  147. })
  148. })
  149. }
  150. async function extractZipArchive(source, destination) {
  151. let isTooLarge
  152. try {
  153. isTooLarge = await ArchiveManager._isZipTooLarge(source)
  154. } catch (err) {
  155. OError.tag(err, 'error checking size of zip file')
  156. throw err
  157. }
  158. if (isTooLarge) {
  159. throw new ZipContentsTooLargeError()
  160. }
  161. const timer = new metrics.Timer('unzipDirectory')
  162. logger.debug({ source, destination }, 'unzipping file')
  163. try {
  164. await _extractZipFiles(source, destination)
  165. } catch (err) {
  166. OError.tag(err, 'unzip failed', { source, destination })
  167. throw err
  168. } finally {
  169. timer.done()
  170. }
  171. }
  172. async function findTopLevelDirectory(directory) {
  173. const files = await fsPromises.readdir(directory)
  174. if (files.length === 1) {
  175. const childPath = Path.join(directory, files[0])
  176. const stat = await fsPromises.stat(childPath)
  177. if (stat.isDirectory()) {
  178. return childPath
  179. }
  180. }
  181. return directory
  182. }
  183. const ArchiveManager = {
  184. _isZipTooLarge,
  185. extractZipArchive: callbackify(extractZipArchive),
  186. findTopLevelDirectory: callbackify(findTopLevelDirectory),
  187. promises: {
  188. extractZipArchive,
  189. findTopLevelDirectory,
  190. },
  191. }
  192. export default ArchiveManager