file_map.js 11 KB

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