file_map.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. // @ts-check
  2. 'use strict'
  3. const _ = require('lodash')
  4. const assert = require('check-types').assert
  5. const OError = require('@overleaf/o-error')
  6. const pMap = require('p-map')
  7. const File = require('./file')
  8. const safePathname = require('./safe_pathname')
  9. /**
  10. * @import { RawFile, RawFileMap } from './types'
  11. *
  12. * @typedef {Record<String, File | null>} FileMapData
  13. */
  14. class PathnameError extends OError {}
  15. class NonUniquePathnameError extends PathnameError {
  16. /**
  17. * @param {string[]} pathnames
  18. */
  19. constructor(pathnames) {
  20. super('pathnames are not unique', { pathnames })
  21. this.pathnames = pathnames
  22. }
  23. }
  24. class BadPathnameError extends PathnameError {
  25. /**
  26. * @param {string} pathname
  27. * @param {string} reason
  28. */
  29. constructor(pathname, reason) {
  30. if (pathname.length > 10) {
  31. pathname = pathname.slice(0, 5) + '...' + pathname.slice(-5)
  32. }
  33. super('invalid pathname', { reason, pathname })
  34. this.pathname = pathname
  35. }
  36. }
  37. class PathnameConflictError extends PathnameError {
  38. /**
  39. * @param {string} pathname
  40. */
  41. constructor(pathname) {
  42. super('pathname conflicts with another file', { pathname })
  43. this.pathname = pathname
  44. }
  45. }
  46. class FileNotFoundError extends PathnameError {
  47. /**
  48. * @param {string} pathname
  49. */
  50. constructor(pathname) {
  51. super('file does not exist', { pathname })
  52. this.pathname = pathname
  53. }
  54. }
  55. /**
  56. * A set of {@link File}s. Several properties are enforced on the pathnames:
  57. *
  58. * 1. File names and paths are case sensitive and can differ by case alone. This
  59. * is consistent with most Linux file systems, but it is not consistent with
  60. * Windows or OS X. Ideally, we would be case-preserving and case insensitive,
  61. * like they are. And we used to be, but it caused too many incompatibilities
  62. * with the old system, which was case sensitive. See
  63. * https://github.com/overleaf/overleaf-ot-prototype/blob/
  64. * 19ed046c09f5a4d14fa12b3ea813ce0d977af88a/editor/core/lib/file_map.js
  65. * for an implementation of this map with those properties.
  66. *
  67. * 2. Uniqueness: No two pathnames are the same.
  68. *
  69. * 3. No type conflicts: A pathname cannot refer to both a file and a directory
  70. * within the same snapshot. That is, you can't have pathnames `a` and `a/b` in
  71. * the same file map; {@see FileMap#wouldConflict}.
  72. */
  73. class FileMap {
  74. static PathnameError = PathnameError
  75. static NonUniquePathnameError = NonUniquePathnameError
  76. static BadPathnameError = BadPathnameError
  77. static PathnameConflictError = PathnameConflictError
  78. static FileNotFoundError = FileNotFoundError
  79. /**
  80. * @param {Record<String, File | null>} files
  81. */
  82. constructor(files) {
  83. // create bare object for use as Map
  84. // http://ryanmorr.com/true-hash-maps-in-javascript/
  85. /** @type FileMapData */
  86. this.files = Object.create(null)
  87. _.assign(this.files, files)
  88. checkPathnamesAreUnique(this.files)
  89. checkPathnamesDoNotConflict(this)
  90. }
  91. /**
  92. * @param {RawFileMap} raw
  93. * @returns {FileMap}
  94. */
  95. static fromRaw(raw) {
  96. assert.object(raw, 'bad raw files')
  97. return new FileMap(_.mapValues(raw, File.fromRaw))
  98. }
  99. /**
  100. * Convert to raw object for serialization.
  101. *
  102. * @return {RawFileMap}
  103. */
  104. toRaw() {
  105. /**
  106. * @param {File} file
  107. * @return {RawFile}
  108. */
  109. function fileToRaw(file) {
  110. return file.toRaw()
  111. }
  112. // TODO(das7pad): refine types to enforce no nulls in FileMapData
  113. // @ts-ignore
  114. return _.mapValues(this.files, fileToRaw)
  115. }
  116. /**
  117. * @returns {Map<string, Record<string, number>>}
  118. */
  119. toStats() {
  120. const sizes = new Map()
  121. for (const [path, file] of Object.entries(this.files)) {
  122. if (!file) continue
  123. sizes.set(path, file.toStats())
  124. }
  125. return sizes
  126. }
  127. /**
  128. * Create the given file.
  129. *
  130. * @param {string} pathname
  131. * @param {File} file
  132. */
  133. addFile(pathname, file) {
  134. checkPathname(pathname)
  135. assert.object(file, 'bad file')
  136. // TODO(das7pad): make ignoredPathname argument fully optional
  137. // @ts-ignore
  138. checkNewPathnameDoesNotConflict(this, pathname)
  139. addFile(this.files, pathname, file)
  140. }
  141. /**
  142. * Remove the given file.
  143. *
  144. * @param {string} pathname
  145. */
  146. removeFile(pathname) {
  147. checkPathname(pathname)
  148. const key = findPathnameKey(this.files, pathname)
  149. if (!key) {
  150. throw new FileMap.FileNotFoundError(pathname)
  151. }
  152. delete this.files[key]
  153. }
  154. /**
  155. * Move or remove a file. If the origin file does not exist, or if the old
  156. * and new paths are identical, this has no effect.
  157. *
  158. * @param {string} pathname
  159. * @param {string} newPathname if a blank string, {@link FileMap#removeFile}
  160. */
  161. moveFile(pathname, newPathname) {
  162. if (pathname === newPathname) return
  163. if (newPathname === '') return this.removeFile(pathname)
  164. checkPathname(pathname)
  165. checkPathname(newPathname)
  166. checkNewPathnameDoesNotConflict(this, newPathname, pathname)
  167. const key = findPathnameKey(this.files, pathname)
  168. if (!key) {
  169. throw new FileMap.FileNotFoundError(pathname)
  170. }
  171. const file = this.files[key]
  172. delete this.files[key]
  173. addFile(this.files, newPathname, file)
  174. }
  175. /**
  176. * The number of files in the file map.
  177. *
  178. * @return {number}
  179. */
  180. countFiles() {
  181. return _.size(this.files)
  182. }
  183. /**
  184. * Get a file by its pathname.
  185. *
  186. * @param {string} pathname
  187. * @return {File | null | undefined}
  188. */
  189. getFile(pathname) {
  190. const key = findPathnameKey(this.files, pathname)
  191. if (key) return this.files[key]
  192. }
  193. /**
  194. * Whether the given pathname conflicts with any file in the map.
  195. *
  196. * Paths conflict in type if one path is a strict prefix of the other path. For
  197. * example, 'a/b' conflicts with 'a', because in the former case 'a' is a
  198. * folder, but in the latter case it is a file. Similarly, the pathname 'a/b/c'
  199. * conflicts with 'a' and 'a/b', but it does not conflict with 'a/b/c', 'a/x',
  200. * or 'a/b/x'. (In our case, identical paths don't conflict, because AddFile
  201. * and MoveFile overwrite existing files.)
  202. *
  203. * @param {string} pathname
  204. * @param {string?} ignoredPathname pretend this pathname does not exist
  205. */
  206. wouldConflict(pathname, ignoredPathname) {
  207. checkPathname(pathname)
  208. assert.maybe.string(ignoredPathname)
  209. const pathnames = this.getPathnames()
  210. const dirname = pathname + '/'
  211. // Check the filemap to see whether the supplied pathname is a
  212. // parent of any entry, or any entry is a parent of the pathname.
  213. for (let i = 0; i < pathnames.length; i++) {
  214. // First check if pathname is a strict prefix of pathnames[i] (and that
  215. // pathnames[i] is not ignored)
  216. if (
  217. pathnames[i].startsWith(dirname) &&
  218. !pathnamesEqual(pathnames[i], ignoredPathname)
  219. ) {
  220. return true
  221. }
  222. // Now make the reverse check, whether pathnames[i] is a strict prefix of
  223. // pathname. To avoid expensive string concatenation on each pathname we
  224. // first perform a partial check with a.startsWith(b), and then do the
  225. // full check for a subsequent '/' if this passes. This saves about 25%
  226. // of the runtime. Again only return a conflict if pathnames[i] is not
  227. // ignored.
  228. if (
  229. pathname.startsWith(pathnames[i]) &&
  230. pathname.length > pathnames[i].length &&
  231. pathname[pathnames[i].length] === '/' &&
  232. !pathnamesEqual(pathnames[i], ignoredPathname)
  233. ) {
  234. return true
  235. }
  236. }
  237. // No conflicts - after excluding ignoredPathname, there were no entries
  238. // which were a strict prefix of pathname, and pathname was not a strict
  239. // prefix of any entry.
  240. return false
  241. }
  242. /** @see Snapshot#getFilePathnames */
  243. getPathnames() {
  244. return _.keys(this.files)
  245. }
  246. /**
  247. * Map the files in this map to new values.
  248. * @template T
  249. * @param {(file: File | null, path: string) => T} iteratee
  250. * @return {Record<String, T>}
  251. */
  252. map(iteratee) {
  253. return _.mapValues(this.files, iteratee)
  254. }
  255. /**
  256. * Map the files in this map to new values asynchronously, with an optional
  257. * limit on concurrency.
  258. * @template T
  259. * @param {(file: File | null | undefined, path: string, pathnames: string[]) => T} iteratee
  260. * @param {number} [concurrency]
  261. * @return {Promise<Record<String, T>>}
  262. */
  263. async mapAsync(iteratee, concurrency) {
  264. assert.maybe.number(concurrency, 'bad concurrency')
  265. const pathnames = this.getPathnames()
  266. const files = await pMap(
  267. pathnames,
  268. file => {
  269. return iteratee(this.getFile(file), file, pathnames)
  270. },
  271. { concurrency: concurrency || 1 }
  272. )
  273. return _.zipObject(pathnames, files)
  274. }
  275. }
  276. /**
  277. * @param {string} pathname0
  278. * @param {string?} pathname1
  279. * @returns {boolean}
  280. */
  281. function pathnamesEqual(pathname0, pathname1) {
  282. return pathname0 === pathname1
  283. }
  284. /**
  285. * @param {FileMapData} files
  286. * @returns {boolean}
  287. */
  288. function pathnamesAreUnique(files) {
  289. const keys = _.keys(files)
  290. return _.uniqWith(keys, pathnamesEqual).length === keys.length
  291. }
  292. /**
  293. * @param {FileMapData} files
  294. */
  295. function checkPathnamesAreUnique(files) {
  296. if (pathnamesAreUnique(files)) return
  297. throw new FileMap.NonUniquePathnameError(_.keys(files))
  298. }
  299. /**
  300. * @param {string} pathname
  301. */
  302. function checkPathname(pathname) {
  303. assert.nonEmptyString(pathname, 'bad pathname')
  304. const [isClean, reason] = safePathname.isCleanDebug(pathname)
  305. if (isClean) return
  306. throw new FileMap.BadPathnameError(pathname, reason)
  307. }
  308. /**
  309. * @param {FileMap} fileMap
  310. * @param {string} pathname
  311. * @param {string?} ignoredPathname
  312. */
  313. function checkNewPathnameDoesNotConflict(fileMap, pathname, ignoredPathname) {
  314. if (fileMap.wouldConflict(pathname, ignoredPathname)) {
  315. throw new FileMap.PathnameConflictError(pathname)
  316. }
  317. }
  318. /**
  319. * @param {FileMap} fileMap
  320. */
  321. function checkPathnamesDoNotConflict(fileMap) {
  322. const pathnames = fileMap.getPathnames()
  323. // check pathnames for validity first
  324. pathnames.forEach(checkPathname)
  325. // convert pathnames to candidate directory names
  326. const dirnames = []
  327. for (let i = 0; i < pathnames.length; i++) {
  328. dirnames[i] = pathnames[i] + '/'
  329. }
  330. // sort in lexical order and check if one directory contains another
  331. dirnames.sort()
  332. for (let i = 0; i < dirnames.length - 1; i++) {
  333. if (dirnames[i + 1].startsWith(dirnames[i])) {
  334. // strip trailing slash to get original pathname
  335. const conflictPathname = dirnames[i + 1].substr(0, -1)
  336. throw new FileMap.PathnameConflictError(conflictPathname)
  337. }
  338. }
  339. }
  340. /**
  341. * This function is somewhat vestigial: it was used when this map used
  342. * case-insensitive pathname comparison. We could probably simplify some of the
  343. * logic in the callers, but in the hope that we will one day return to
  344. * case-insensitive semantics, we've just left things as-is for now.
  345. *
  346. * TODO(das7pad): In a followup, inline this function and make types stricter.
  347. *
  348. * @param {FileMapData} files
  349. * @param {string} pathname
  350. * @returns {string | undefined}
  351. */
  352. function findPathnameKey(files, pathname) {
  353. // we can check for the key without worrying about properties
  354. // in the prototype because we are now using a bare object/
  355. if (pathname in files) return pathname
  356. }
  357. /**
  358. * @param {FileMapData} files
  359. * @param {string} pathname
  360. * @param {File?} file
  361. */
  362. function addFile(files, pathname, file) {
  363. const key = findPathnameKey(files, pathname)
  364. if (key) delete files[key]
  365. files[pathname] = file
  366. }
  367. module.exports = FileMap