file_map.js 9.5 KB

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