| 1 | import { KvStorage } from '@rejetto/kvstorage' |
| 2 | import { promisify } from 'util' |
| 3 | import { access } from 'fs/promises' |
| 4 | import { onlyTruthy, try_, tryJson } from './cross' |
| 5 | import { onProcessExit } from './first' |
| 6 | import { utimes } from 'node:fs/promises' |
| 7 | import { statWithTimeout } from './util-files' |
| 8 | import { IS_WINDOWS } from './const' |
| 9 | import { isAbsolute, join, relative } from 'path' |
| 10 | |
| 11 | const fsx = try_(() => { |
| 12 | const lib = require('fs-x-attributes') |
| 13 | return { set: promisify(lib.set), get: promisify(lib.get) } |
| 14 | }, () => console.warn('fs-x-attributes not available')) |
| 15 | |
| 16 | const FN = 'file-attr.kv' |
| 17 | export const fileAttrDb = new KvStorage({ defaultPutDelay: 1000, maxPutDelay: 5000 }) |
| 18 | onProcessExit(() => fileAttrDb.close()) |
| 19 | fileAttrDb.open(FN).catch(e => |
| 20 | console.error(String(e))) |
| 21 | const FILE_ATTR_PREFIX = 'user.hfs.' // user. prefix to be linux compatible |
| 22 | const FILE_ATTR_KEY_SEPARATOR = '|' |
| 23 | |
| 24 | /* @param v must be JSON-able or undefined */ |
| 25 | export async function storeFileAttr(path: string, k: string, v: any) { |
| 26 | const s = await statWithTimeout(path).catch(() => null) |
| 27 | // since we don't have fsx.remove, we simulate it with an empty string |
| 28 | if (s && await fsx?.set(path, FILE_ATTR_PREFIX + k, v === undefined ? '' : JSON.stringify(v)).then(() => 1, () => 0)) { |
| 29 | if (IS_WINDOWS) utimes(path, s.atime, s.mtime) // restore timestamps, necessary only on Windows |
| 30 | return true |
| 31 | } |
| 32 | // fallback to our kv-storage |
| 33 | return await fileAttrDb.put(fileAttrKey(path, k), v)?.catch((e: any) => { |
| 34 | console.error("Couldn't store metadata on", path, String(e.message || e)) |
| 35 | return false |
| 36 | }) ?? true // if put is undefined, the value was already there |
| 37 | } |
| 38 | |
| 39 | export async function loadFileAttr(path: string, k: string) { |
| 40 | return await fsx?.get(path, FILE_ATTR_PREFIX + k) |
| 41 | .then((x: any) => x === '' ? undefined : tryJson(String(x)), |
| 42 | () => fileAttrDb.isOpen() ? fileAttrDb.get(fileAttrKey(path, k)).catch(console.error) : null) |
| 43 | ?? undefined // normalize, as we get null instead of undefined on windows |
| 44 | } |
| 45 | |
| 46 | // remove file-attr for files that don't exist anymore |
| 47 | export async function purgeFileAttr() { |
| 48 | let n = 0 |
| 49 | await Promise.all(Array.from(fileAttrDb.keys()).map(k => { |
| 50 | const fn = splitFileAttrKey(k)?.filePath |
| 51 | return fn && access(fn).catch(() => { |
| 52 | n++ |
| 53 | return fileAttrDb.del(k) |
| 54 | }) |
| 55 | })) |
| 56 | if (n) |
| 57 | await fileAttrDb.rewrite() |
| 58 | console.log(`Removed ${n} entrie(s)`) |
| 59 | } |
| 60 | |
| 61 | export async function moveStoredFileAttrs(fromPath: string, toPath: string) { |
| 62 | try { |
| 63 | if (fromPath === toPath || !fileAttrDb.isOpen()) |
| 64 | return |
| 65 | const entries = storedFileAttrEntries() |
| 66 | const affectedEntries = entries.filter(x => isSameOrInside(fromPath, x.filePath)) |
| 67 | if (!affectedEntries.length) |
| 68 | return |
| 69 | const affectedWithValues = await Promise.all(affectedEntries.map(async x => ({ |
| 70 | ...x, |
| 71 | value: await fileAttrDb.get(x.key) |
| 72 | }))) |
| 73 | const oldDestinationKeys = entries.filter(x => isSameOrInside(toPath, x.filePath)).map(x => x.key) |
| 74 | // destination attrs must be cleared first because a replaced file may not have all attrs owned by the source |
| 75 | await Promise.all(oldDestinationKeys.map(k => fileAttrDb.del(k))) |
| 76 | await Promise.all(affectedWithValues.map(async ({ key, filePath, attr, value }) => { |
| 77 | const rel = relative(fromPath, filePath) |
| 78 | // physical path keys the fallback DB, so filesystem moves must carry descendant entries explicitly |
| 79 | await fileAttrDb.put(fileAttrKey(join(toPath, rel), attr), value) |
| 80 | await fileAttrDb.del(key) |
| 81 | })) |
| 82 | } |
| 83 | // metadata sync runs after the filesystem mutation, so it must not report the completed file operation as failed |
| 84 | catch(e: any) { console.error("Couldn't move metadata in file-attr DB", fromPath, toPath, String(e.message || e)) } |
| 85 | } |
| 86 | |
| 87 | export async function deleteStoredFileAttrs(path: string) { |
| 88 | try { |
| 89 | if (!fileAttrDb.isOpen()) |
| 90 | return |
| 91 | const keys = storedFileAttrEntries().filter(x => isSameOrInside(path, x.filePath)).map(x => x.key) |
| 92 | await Promise.all(keys.map(k => fileAttrDb.del(k))) |
| 93 | } |
| 94 | // metadata cleanup runs after deletion, so surfacing this would leave clients seeing a false delete failure |
| 95 | catch(e: any) { console.error("Couldn't delete metadata from file-attr DB", path, String(e.message || e)) } |
| 96 | } |
| 97 | |
| 98 | function storedFileAttrEntries() { |
| 99 | return onlyTruthy(Array.from(fileAttrDb.keys()).map(splitFileAttrKey)) |
| 100 | } |
| 101 | |
| 102 | function splitFileAttrKey(key: string) { |
| 103 | const i = key.lastIndexOf(FILE_ATTR_KEY_SEPARATOR) |
| 104 | if (i < 0) |
| 105 | return |
| 106 | return { |
| 107 | key, |
| 108 | filePath: key.slice(0, i), |
| 109 | attr: key.slice(i + 1) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | function fileAttrKey(path: string, attr: string) { |
| 114 | return path + FILE_ATTR_KEY_SEPARATOR + attr |
| 115 | } |
| 116 | |
| 117 | function isSameOrInside(parent: string, path: string) { |
| 118 | const rel = relative(parent, path) |
| 119 | return rel === '' || Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel) |
| 120 | } |