file_map.js 9.6 KB

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