UNPKG

7-sync

Version:

A wrapper for 7-Zip to create cloud-friendly encrypted backups

1,228 lines (1,212 loc) 150 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; class Application { constructor(logger) { this.logger = logger; } static main() { return __awaiter(this, void 0, void 0, function* () { const application = new Application(new Logger(LogLevel.ERROR, new NullOutputStream())); try { const SKIPPED_ARGC = 2; process.exit(yield application.run(process.argv.slice(SKIPPED_ARGC))); } catch (exception) { if (exception instanceof FriendlyException) { if (0 === exception.exitCode) { console.log(`${exception.message}`); } else { console.error(`ERROR: ${exception.message}`); } application.logger.error(exception.message); } else { console.error(exception); application.logger.error(`${exception}`); } node.process.exit(exception instanceof FriendlyException ? exception.exitCode : 1); } }); } run(argv) { return __awaiter(this, void 0, void 0, function* () { this.logger.info("Parsing the command line options"); const options = CommandLineParser.parse(argv); if (!options.config.endsWith(".cfg")) { throw new FriendlyException(`"${options.config}" does not end with .cfg`); } this.logger.debug("Extracted command line options:", options); switch (options.command) { case CommandLineParser.DEFAULT_OPTIONS.init.command: yield SetupWizard.initialize(options); return 0; case CommandLineParser.DEFAULT_OPTIONS.reconfigure.command: yield SetupWizard.reconfigure(options); return 0; case CommandLineParser.DEFAULT_OPTIONS.sync.command: return this.sync(yield Context.of(options)); default: return assertNever(options); } }); } sync(context) { return __awaiter(this, void 0, void 0, function* () { try { yield context.sevenZip.runSelfTest(); const metadataManager = new MetadataManager(context); const { json, mustSaveImmediately } = yield metadataManager.loadOrInitializeDatabase(); const database = DatabaseAssembler.assemble(context, json); if (mustSaveImmediately && !metadataManager.updateIndex(database).isUpToDate) { throw new FriendlyException("Failed to save the database"); } else { database.markAsSaved(); } const message = context.options.dryRun ? "Starting the dry run" : "Starting the synchronization"; context.logger.info(message); context.print(message); return yield Synchronizer.run(context, metadataManager, database); } catch (exception) { if (exception instanceof FriendlyException) { exception.message .split(/\r?\n/) .map(line => line.trim()) .filter(line => line) .forEach(line => context.logger.error(line)); } else { context.logger.error(firstLineOnly(exception)); } context.console.log(""); throw exception; } }); } } let hasStarted = false; process.on("beforeExit", () => { if (!hasStarted) { hasStarted = true; Application.main(); } }); const APPLICATION_VERSION = "1.1.2"; const COPYRIGHT_OWNER = "David Hofmann"; const COPYRIGHT_YEARS = "2022-2023"; class Context { constructor(options, config, files, logger, console, filenameEnumerator, sevenZip) { this.options = options; this.config = config; this.files = files; this.logger = logger; this.console = console; this.filenameEnumerator = filenameEnumerator; this.sevenZip = sevenZip; this.print = (message) => this.console.log(message); } static of(options) { var _a; return __awaiter(this, void 0, void 0, function* () { const password = (_a = options.password) !== null && _a !== void 0 ? _a : node.process.env["SEVEN_SYNC_PASSWORD"]; delete options.password; const console = options.silent ? new NullOutputStream() : new ConsoleOutputStream(); const files = Context.getFileNames(options.config); yield Logger.purge(files.log, Context.MAX_LOG_FILES); const logger = Context.getLogger(files.log, false); logger.separator(); try { logger.info(`7-sync started in ${FileUtils.getAbsolutePath(".")}`); const config = Context.getConfig(files.config, options, logger); const validatedPassword = yield this.getValidatedPassword(config.password, password); const sevenZip = new SevenZip(config.sevenZip, validatedPassword, logger, console); const filenameEnumerator = new FilenameEnumerator(logger); logger.info(`Source .......... ${config.source}`); logger.info(`Destination ..... ${config.destination}`); logger.info(`Configuration ... ${FileUtils.getAbsolutePath(files.config)}`); logger.info(`Log file ........ ${FileUtils.getAbsolutePath(files.log)}`); logger.info(`7-Zip command ... ${config.sevenZip}`); logger.info(`Dry-run ......... ${options.dryRun}`); return new Context(options, config, files, logger, console, filenameEnumerator, sevenZip); } catch (exception) { logger.error(exception instanceof FriendlyException ? exception.message : firstLineOnly(exception)); throw exception; } }); } static getFileNames(configFile) { const result = ConfigValidator.validateConfigFile(configFile, true); if (true === result) { return { config: FileUtils.getAbsolutePath(configFile), log: FileUtils.getAbsolutePath(FileUtils.resolve(configFile, configFile.replace(/(\.cfg)?$/, ".log"))) }; } else { throw new FriendlyException(result); } } static getLogger(file, verbose) { return new Logger(verbose ? LogLevel.DEBUG : LogLevel.INFO, new FileOutputStream(file, true)); } static getConfig(configFile, options, logger) { const json = JsonParser.loadAndValidateConfig(options, logger).finalConfig; const validationResult = ConfigValidator.validateConfiguration(configFile, json); if (true === validationResult) { return json; } else { throw new FriendlyException(`Invalid configuration: ${validationResult}`); } } static getValidatedPassword(saltedHash, password) { return __awaiter(this, void 0, void 0, function* () { password = password !== null && password !== void 0 ? password : yield this.promptForPassword(saltedHash); if (!PasswordHelper.validatePassword(password, saltedHash)) { throw new FriendlyException("Invalid password"); } return password; }); } static promptForPassword(saltedHash) { return __awaiter(this, void 0, void 0, function* () { return InteractivePrompt.prompt({ question: "Please enter the password.", isPassword: true, useStderr: true, validate: input => { console.error(""); const isCorrect = PasswordHelper.validatePassword(input, saltedHash); if (!isCorrect) { console.error("Invalid password. Please try again."); } return isCorrect; } }); }); } } Context.MAX_LOG_FILES = 9; class RootDirectory { constructor(absolutePath) { this.absolutePath = absolutePath; } } class Subdirectory extends RootDirectory { constructor(parent, name) { super(node.path.join(parent.absolutePath, name)); this.name = name; this.relativePath = parent instanceof Subdirectory ? node.path.join(parent.relativePath, name) : name; } } class MappedDirectoryBase { constructor(source, destination, _last) { this.source = source; this.destination = destination; this._last = _last; this._files = { bySourceName: new Map(), byDestinationName: new Map() }; this.files = { bySourceName: new ImmutableMap(this._files.bySourceName), byDestinationName: new ImmutableMap(this._files.byDestinationName) }; this._subdirectories = { bySourceName: new Map(), byDestinationName: new Map() }; this.subdirectories = { bySourceName: new ImmutableMap(this._subdirectories.bySourceName), byDestinationName: new ImmutableMap(this._subdirectories.byDestinationName) }; } get last() { return this._last; } set last(last) { this._last = last; this.markAsModified(); } add(fileOrSubdirectory) { this.markAsModified(); if (fileOrSubdirectory instanceof MappedFile) { this.addTo(this._files.bySourceName, fileOrSubdirectory.source.name, fileOrSubdirectory); this.addTo(this._files.byDestinationName, fileOrSubdirectory.destination.name, fileOrSubdirectory); } else { this.addTo(this._subdirectories.bySourceName, fileOrSubdirectory.source.name, fileOrSubdirectory); this.addTo(this._subdirectories.byDestinationName, fileOrSubdirectory.destination.name, fileOrSubdirectory); } } addTo(map, key, value) { if (map.has(key)) { const type = value instanceof MappedFile ? "File" : "Subdirectory"; const path = value.source.relativePath; throw new Error(`Internal error: ${type} ${path} has already been added to the database`); } else { map.set(key, value); } } delete(fileOrSubdirectory) { this.markAsModified(); if (fileOrSubdirectory instanceof MappedFile) { this.deleteFrom(this._files.bySourceName, fileOrSubdirectory.source.name, fileOrSubdirectory); this.deleteFrom(this._files.byDestinationName, fileOrSubdirectory.destination.name, fileOrSubdirectory); } else { this.deleteFrom(this._subdirectories.bySourceName, fileOrSubdirectory.source.name, fileOrSubdirectory); this.deleteFrom(this._subdirectories.byDestinationName, fileOrSubdirectory.destination.name, fileOrSubdirectory); } } deleteFrom(map, key, value) { const mapValue = map.get(key); if (undefined === mapValue) { throw new Error(`Internal error: Directory entry ${key} does not exist`); } else if (mapValue !== value) { throw new Error(`Internal error: ${key} points to the wrong directory entry`); } else { map.delete(key); } } countChildren(statistics) { const realStatistics = statistics !== null && statistics !== void 0 ? statistics : { files: 0, subdirectories: 0 }; realStatistics.files += this._files.byDestinationName.size; realStatistics.subdirectories += this._subdirectories.byDestinationName.size; this._subdirectories.byDestinationName.forEach(subdirectory => subdirectory.countChildren(realStatistics)); return realStatistics; } } class MappedRootDirectory extends MappedDirectoryBase { constructor(source, destination, last) { super(source, destination, last); this._hasUnsavedChanges = true; } hasUnsavedChanges() { return this._hasUnsavedChanges; } wasSavedWithinTheLastSeconds(seconds) { return undefined === this.lastSavedAtMs ? false : new Date().getTime() - this.lastSavedAtMs <= seconds * MappedRootDirectory.MILLISECONDS_PER_SECOND; } markAsSaved(saved = true) { this.lastSavedAtMs = new Date().getTime(); this._hasUnsavedChanges = !saved; } markAsModified() { this._hasUnsavedChanges = true; } } MappedRootDirectory.MILLISECONDS_PER_SECOND = 1000; class MappedSubdirectory extends MappedDirectoryBase { constructor(parent, source, destination, last) { super(source, destination, last); this.parent = parent; } markAsModified() { this.parent.markAsModified(); } } class FriendlyException extends Error { constructor(message, exitCode = 1) { super(message); this.exitCode = exitCode; } } class InternalError extends Error { constructor(message) { super(`Internal error: ${message}`); } } class File { constructor(parent, name) { this.parent = parent; this.name = name; this.absolutePath = node.path.join(parent.absolutePath, name); this.relativePath = parent instanceof Subdirectory ? node.path.join(parent.relativePath, name) : name; } } class MappedFile { constructor(parent, source, destination, created, modified, size) { this.parent = parent; this.source = source; this.destination = destination; this.created = created; this.modified = modified; this.size = size; } } class ImmutableMap { constructor(map) { this.map = map; } has(key) { return this.map.has(key); } values() { return Array.from(this.map.values()); } sorted() { return Array.from(this.map.entries()).sort((entry1, entry2) => { const name1 = entry1[0].toLowerCase(); const name2 = entry2[0].toLowerCase(); if (name1 < name2) { return -1; } else if (name1 === name2) { return 0; } else { return 1; } }).map(entry => entry[1]); } } class Optional { constructor(value) { this.value = value; if (null === this.value) { this.value = undefined; } } static create(value) { if (undefined === value || null === value) { return Optional.EMPTY; } else { return new Optional(value); } } static of(value) { return Optional.create(value); } static empty() { return Optional.create(); } get() { return this.value; } getOrThrow() { if (undefined === this.value) { throw new Error("The Optional is empty"); } else { return this.value; } } getOrDefault(defaultValue) { if (undefined === this.value) { return defaultValue; } else { return this.value; } } getOrCalculate(supplier) { if (undefined === this.value) { return supplier(); } else { return this.value; } } isPresent() { return undefined !== this.value; } isEmpty() { return undefined === this.value; } ifPresent(action) { if (undefined !== this.value) { action(this.value); } return this; } ifEmpty(action) { if (undefined === this.value) { action(); } return this; } map(mapper) { return undefined === this.value ? Optional.EMPTY : Optional.create(mapper(this.value)); } flatMap(mapper) { if (undefined === this.value) { return Optional.EMPTY; } else { const optional = mapper(this.value); if (optional instanceof Optional) { return optional; } else { throw new Error("The mapping function did not return an Optional instance"); } } } filter(filter) { if (undefined !== this.value && filter(this.value)) { return this; } else { return Optional.EMPTY; } } } Optional.EMPTY = new Optional(); const README_URL_BASE = "https://github.com/david-04/7-sync/blob/main/README.md"; const README_URL_WARNINGS = `${README_URL_BASE}#user-content-errors`; const README_URL_RESTORE = `${README_URL_BASE}#user-content-restoring-backups`; const README_FILE_CONTENT = ` ------------------------------------------------------------------------------- 7-sync restore/recovery instructions ------------------------------------------------------------------------------- This is an extract from the 7-sync manual. It can be found here: https://github.com/david-04/7-sync/blob/main/README.md#user-content-restoring-backups The following instructions explain how to restore some or all files from this backup. 1. If the backup is stored in the cloud, download it first. - To restore the whole backup, download all files and directories. - To only restore selected files, open 7-sync-file-index.txt (stored in the same archive: ___INDEX___2022-04-10-07-25-47-394.7z). Look up the respective files and directories and download them from the cloud storage as required. 2. Place all files that need to be unzipped/restored in one folder. 3. Open 7-Zip and navigate to the folder containing the encrypted files. 4. In the "View" menu, enable the "Flat View" option. This will show all files from all subdirectories in one list. 5. Select all the files (but no directories). - Click on the first file in the list. - Scroll down to the bottom of the list. - While pressing the "Shift" key, click on the last file in the list. 6. Click on the "Extract" button in the toolbar and configure how and where to extract the files: - Set the "Extract to" field to the directory where to place the decrypted files. The path must NOT contain "*" (like for example C:\\Restore\\*\\). Also untick the checkbox right under this field. Otherwise, 7-Zip creates a separate subdirectory for each file being unzipped. - Set the "Path mode" to "Full pathnames". - Tick "Eliminate duplication of root folder". - Enter the password. 7. Click on "OK". If the password is correct, 7-Zip will unpack all files. `.trim().replace(/\n {4}/g, "\n").replace(/\r/g, "").trim() + "\n"; class SuccessAndFailureStats { constructor(...statistics) { this.success = 0; this.failed = 0; statistics.forEach(item => { this.success += item.success; this.failed += item.failed; }); } get total() { return this.success + this.failed; } } class FileAndDirectoryStats { constructor(...statistics) { this.files = new SuccessAndFailureStats(...statistics.map(item => item.files)); this.directories = new SuccessAndFailureStats(...statistics.map(item => item.directories)); } get total() { return this.files.total + this.directories.total; } get success() { return this.files.success + this.directories.success; } } class SyncStats { constructor() { this.copied = new FileAndDirectoryStats(); this.deleted = new FileAndDirectoryStats(); this.orphans = new FileAndDirectoryStats(); this.purged = new FileAndDirectoryStats(); this.unprocessable = { source: { symlinks: 0, other: 0 }, destination: { symlinks: 0, other: 0 } }; this.index = { hasLingeringOrphans: false, isUpToDate: true }; } get success() { return this.copied.success || this.deleted.success || this.orphans.success || this.purged.success; } } class AsyncTaskPool { constructor(maxParallelTasks) { this.maxParallelTasks = maxParallelTasks; this.tasks = new DoubleLinkedList(); this.promises = new Array(); this.runningTaskCount = 0; } enqueue(callback) { this.tasks.append(callback); this.startNextTask(); } startNextTask() { const task = this.tasks.head; if (task && this.runningTaskCount < this.maxParallelTasks) { this.tasks.remove(task); this.runningTaskCount++; const execute = () => __awaiter(this, void 0, void 0, function* () { yield task.value(); this.runningTaskCount--; this.startNextTask(); }); this.promises.push(execute()); } } waitForAllTasksToComplete() { return __awaiter(this, void 0, void 0, function* () { do { yield Promise.all(this.promises); } while (this.tasks.head || 0 < this.runningTaskCount); yield Promise.all(this.promises); }); } } var _a; class CommandLineParser { static showUsageAndExit() { const configFile = this.DEFAULT_CONFIG_FILE; const parallel = this.DEFAULT_OPTIONS.sync.parallel; this.exitWithMessage(` Create an encrypted copy of a directory using 7-Zip. | | Usage: 7-sync [command] [options] | | Commands: | | ${this.DEFAULT_OPTIONS.init.command} create a new configuration file | ${this.DEFAULT_OPTIONS.reconfigure.command} change the configuration file | ${this.DEFAULT_OPTIONS.sync.command} sync files (or perform a dry run) | | Options: | | --${this.OPTIONS.sevenZip}=<7_ZIP_EXECUTABLE> the 7-Zip executable to use | --${this.OPTIONS.config}=<CONFIG_FILE> use the given configuration file (default: ${configFile}) | --${this.OPTIONS.dryRun} perform a trial run without making any changes | --${this.OPTIONS.help} display this help and exit | --${this.OPTIONS.parallel}=<NO_OF_JOBS> zip multiple files in parallel (default: ${parallel}) | --${this.OPTIONS.password}=<PASSWORD> use this password instead of prompting for it | --${this.OPTIONS.silent} suppress console output | --${this.OPTIONS.version} display version information and exit | | The password can also be stored as environment variable SEVEN_SYNC_PASSWORD. | | Full documentation: ${README_URL_BASE} `.trim().replace(/^\s+/gm, "").replace(/^\| ?/gm, "")); } static showVersionAndExit() { this.exitWithMessage(` 7-sync ${APPLICATION_VERSION} Copyright (c) ${COPYRIGHT_YEARS} ${COPYRIGHT_OWNER} License: MIT <https://opensource.org/licenses/MIT> `.trim().replace(/^\s+/gm, "")); } static parse(argv) { if (argv.filter(parameter => parameter.match(/^--?(v|version)$/)).length) { return this.showVersionAndExit(); } else if (argv.filter(parameter => parameter.match(/^--?(h|help)$/)).length) { return this.showUsageAndExit(); } const { commands, options } = this.splitParameters(argv); if (0 === commands.length) { return this.exitWithError("Missing command"); } else if (1 < commands.length) { return this.exitWithError(`More than one command specified: ${commands.join(", ")}`); } else { return this.assembleOptions(commands[0], options); } } static splitParameters(argv) { const options = new Map(); const commands = new Array(); const prefix = "--"; const separator = "="; const minLength = prefix.length + separator.length; argv.forEach(argument => { if (argument.startsWith(prefix)) { const index = argument.indexOf(separator); const key = minLength <= index ? argument.substring(prefix.length, index).trim() : argument.substring(prefix.length).trim(); const value = minLength <= index ? argument.substring(index + separator.length) : true; options.set(key, "string" === typeof value && this.OPTIONS.password !== key ? value.trim() : value); } else { this.getInternalKey(this.DEFAULT_OPTIONS, argument, false); commands.push(argument); } }); return { options, commands }; } static assembleOptions(command, suppliedOptions) { const mergedOptions = Object.assign({}, this.getDefaultOptions(command)); suppliedOptions.forEach((suppliedValue, suppliedKey) => { this.setOption(command, mergedOptions, suppliedKey, suppliedValue); }); return mergedOptions; } static getDefaultOptions(command) { const defaultOptionsMap = this.DEFAULT_OPTIONS; const defaultOptions = defaultOptionsMap[this.getInternalKey(this.DEFAULT_OPTIONS, command, false)]; return defaultOptions ? defaultOptions : this.exitWithError(`Internal error - no default options for command "${command}"`); } static setOption(command, defaultOptions, suppliedKey, suppliedValue) { const defaultKey = this.getInternalKey(this.OPTIONS, suppliedKey, true); if ("command" === suppliedKey || !(defaultKey in defaultOptions)) { this.exitWithError(`Command "${command}" does not support option --${suppliedKey}`); } const defaultValue = asAny(defaultOptions)[defaultKey]; if ("boolean" === typeof defaultValue) { if ("boolean" !== typeof suppliedValue) { this.exitWithError(`Option --${suppliedKey} can't have a value assigned`); } } else { if ("string" !== typeof suppliedValue || !suppliedValue) { this.exitWithError(`Option --${suppliedKey} requires a value`); } } asAny(defaultOptions)[defaultKey] = "number" === typeof defaultValue ? CommandLineParser.parseOptionAsNumber(suppliedValue, suppliedKey) : suppliedValue; } static parseOptionAsNumber(suppliedValue, suppliedKey) { const parsedNumber = parseInt(asAny(suppliedValue)); if (isNaN(parsedNumber)) { this.exitWithError(`Invalid value for --${suppliedKey} (${suppliedValue} is not a number)`); } if (suppliedKey === this.OPTIONS.parallel && parsedNumber < 1) { this.exitWithError(`Invalid value for --${suppliedKey} (it must be 1 or greater)`); } return parsedNumber; } static getInternalKey(mapping, suppliedKey, isOption) { for (const internalKey of Object.keys(mapping)) { const mappedValue = mapping[internalKey]; const externalKey = "string" === typeof mappedValue ? mappedValue : mappedValue.command; if (suppliedKey === externalKey) { return internalKey; } } if (isOption) { return this.exitWithError(`Invalid option --${suppliedKey}`); } else { return this.exitWithError(`Invalid argument "${suppliedKey}"`); } } static as(value) { return value; } static exitWithError(message) { throw new FriendlyException(` ${message} Try '7-sync --${this.OPTIONS.help}' for more information `.trim().replace(/^\s+/gm, "")); } static exitWithMessage(message) { throw new FriendlyException(message, 0); } } _a = CommandLineParser; CommandLineParser.DEFAULT_CONFIG_FILE = "7-sync.cfg"; CommandLineParser.DEFAULT_7_ZIP_EXECUTABLE = "7z"; CommandLineParser.OPTIONS = { config: "config", dryRun: "dry-run", password: "password", parallel: "parallel", sevenZip: "7-zip", silent: "silent", help: "help", version: "version" }; CommandLineParser.SHARED_DEFAULT_OPTIONS = { config: _a.DEFAULT_CONFIG_FILE }; CommandLineParser.DEFAULT_OPTIONS = { sync: _a.as(Object.assign(Object.assign({ command: "sync" }, _a.SHARED_DEFAULT_OPTIONS), { dryRun: false, password: undefined, sevenZip: undefined, silent: false, parallel: 2 })), init: _a.as(Object.assign({ command: "init" }, _a.SHARED_DEFAULT_OPTIONS)), reconfigure: _a.as(Object.assign({ command: "reconfigure" }, _a.SHARED_DEFAULT_OPTIONS)) }; class ConfigValidator { static validateConfiguration(configFile, json) { var _a; return (_a = [ this.validateConfigFile(configFile, true), this.validateSourceDirectory(configFile, json.source), this.validateDestinationDirectory(configFile, json.source, json.destination) ].find(result => true !== result && undefined !== result)) !== null && _a !== void 0 ? _a : true; } static validateConfigFile(config, mustExist) { const directory = FileUtils.getParent(config); if (mustExist && !FileUtils.exists(config)) { return `Config file "${config}" does not exist`; } else if (mustExist && !FileUtils.existsAndIsFile(config)) { return `Config file "${config}" is not a regular file`; } else if (!config.endsWith(".cfg")) { return `${config} does not end with .cfg`; } else if (FileUtils.existsAndIsFile(config)) { return true; } else if (FileUtils.exists(config)) { return `${config} is not a regular file`; } else if (FileUtils.existsAndIsDirectory(directory)) { return true; } else if (FileUtils.exists(directory)) { return `Directory ${directory} is not a directory`; } else { return `Directory ${directory} does not exist`; } } static validateSourceDirectory(config, source) { const resolvedSource = FileUtils.resolve(config, source !== null && source !== void 0 ? source : ""); if (!FileUtils.exists(resolvedSource)) { return `Source directory ${resolvedSource} does not exist`; } else if (!FileUtils.existsAndIsDirectory(resolvedSource)) { return `${resolvedSource} is not a directory`; } else if (FileUtils.isParentChild(resolvedSource, config)) { return "The source directory must not contain the configuration file"; } else { return true; } } static validateDestinationDirectory(config, source, destination) { const resolvedSource = FileUtils.resolve(config, source !== null && source !== void 0 ? source : ""); const resolvedDestination = FileUtils.resolve(config, destination !== null && destination !== void 0 ? destination : ""); if (!FileUtils.exists(resolvedDestination)) { return `Destination directory ${resolvedDestination} does not exist`; } else if (!FileUtils.existsAndIsDirectory(resolvedDestination)) { return `${resolvedDestination} is not a directory`; } else if (FileUtils.equals(resolvedSource, resolvedDestination)) { return "The destination directory can't be the same as the source directory"; } else if (FileUtils.isParentChild(resolvedDestination, config)) { return "The destination directory must not contain the configuration file"; } else if (FileUtils.isParentChild(resolvedDestination, resolvedSource)) { return "The source directory must not be inside the destination directory"; } else if (FileUtils.isParentChild(resolvedSource, resolvedDestination)) { return "The destination directory must not be inside the source directory."; } else { return true; } } } class DatabaseAssembler { constructor(context) { this.context = context; } static assemble(context, database) { return new DatabaseAssembler(context).assembleDatabase(database); } assembleDatabase(json) { const source = new RootDirectory(this.context.config.source); const destination = new RootDirectory(this.context.config.destination); this.assertThatDirectoriesExist(source, destination); try { const database = new MappedRootDirectory(source, destination, json.last); this.assembleFilesAndSubdirectories(database, json); return database; } catch (exception) { return rethrow(exception, message => `Failed to assemble database - ${message}`); } } assembleFilesAndSubdirectories(directory, json) { json.files.forEach(file => directory.add(this.assembleFile(directory, file))); json.directories.forEach(subdirectory => directory.add(this.assembleDirectory(directory, subdirectory))); } assembleDirectory(parent, json) { const source = new Subdirectory(parent.source, json.source); const destination = new Subdirectory(parent.destination, json.destination); const mappedDirectory = new MappedSubdirectory(parent, source, destination, json.last); this.assembleFilesAndSubdirectories(mappedDirectory, json); return mappedDirectory; } assembleFile(directory, json) { const source = new File(directory.source, json.source); const destination = new File(directory.destination, json.destination); return new MappedFile(directory, source, destination, json.created, json.modified, json.size); } assertThatDirectoriesExist(...directories) { directories.map(directory => directory.absolutePath).forEach(directory => { if (!FileUtils.exists(directory)) { throw new FriendlyException(`Directory ${directory} does not exist`); } else if (!FileUtils.existsAndIsDirectory(directory)) { throw new FriendlyException(`${directory} is not a directory`); } }); } } class DatabaseSerializer { static serializeDatabase(database) { return tryCatchRethrowFriendlyException(() => this.serialize(database), error => `Failed to serialize the database - ${error}`); } static serialize(database) { const json = { directories: database.subdirectories.bySourceName.sorted().map(dir => this.directoryToJson(dir)), files: database.files.bySourceName.sorted().map(file => this.fileToJson(file)), last: database.last }; JsonValidator.validateDatabase(json); return JSON.stringify(json); } static directoryToJson(directory) { return { source: directory.source.name, destination: directory.destination.name, directories: directory.subdirectories.bySourceName.sorted().map(subDir => this.directoryToJson(subDir)), files: directory.files.bySourceName.sorted().map(file => this.fileToJson(file)), last: directory.last }; } static fileToJson(file) { return { source: file.source.name, destination: file.destination.name, created: file.created, modified: file.modified, size: file.size }; } } class DoubleLinkedList { constructor() { this.firstNode = undefined; this.lastNode = undefined; } append(value) { const newNode = { value }; newNode.previous = this.lastNode; if (this.lastNode) { this.lastNode.next = newNode; this.lastNode = newNode; } else { this.firstNode = newNode; this.lastNode = newNode; } return newNode; } remove(nodeToDelete) { if (nodeToDelete.previous) { nodeToDelete.previous.next = nodeToDelete.next; } if (nodeToDelete.next) { nodeToDelete.next.previous = nodeToDelete.previous; } if (nodeToDelete === this.firstNode) { this.firstNode = nodeToDelete.next; } if (nodeToDelete === this.lastNode) { this.lastNode = nodeToDelete.previous; } } get head() { return this.firstNode; } } class FileListingCreator { static create(database) { return this.recurseInto(database, []).join("\n") + "\n"; } static recurseInto(directory, lines) { this.addToIndex(directory, lines); directory.subdirectories.bySourceName.sorted().forEach(subdirectory => this.recurseInto(subdirectory, lines)); directory.files.bySourceName.sorted().forEach(file => this.addToIndex(file, lines)); return lines; } static addToIndex(fileOrDirectory, lines) { if (fileOrDirectory instanceof MappedSubdirectory || fileOrDirectory instanceof MappedFile) { lines.push(`${fileOrDirectory.source.relativePath} => ${fileOrDirectory.destination.relativePath}`); } } } class FileManager { constructor(context, database) { this.context = context; this.database = database; this.print = context.print; this.logger = context.logger; this.isDryRun = context.options.dryRun; } createDirectory(directory, source) { const paths = this.getSourceAndDestinationPaths(directory, source, ""); this.print(`+ ${paths.source.relativePath}`); const pathInfo = this.getLogFilePathInfo("mkdir", paths.destination.absolutePath, paths.source.absolutePath); let newDestinationDirectory; if (this.isDryRun) { this.logger.info(`Would create directory ${pathInfo}`); newDestinationDirectory = new Subdirectory(directory.destination, paths.destination.filename); } else { this.logger.info(`Creating directory ${pathInfo}`); try { node.fs.mkdirSync(paths.destination.absolutePath); if (!FileUtils.exists(paths.destination.absolutePath)) { throw new Error("No exception was raised"); } newDestinationDirectory = new Subdirectory(directory.destination, paths.destination.filename); } catch (exception) { this.logger.error(`Failed to create directory ${paths.destination.absolutePath} - ${firstLineOnly(exception)}`); this.print(FileManager.LOG_MESSAGE_FAILED); } } return newDestinationDirectory ? this.storeNewSubdirectory(directory, source.name, newDestinationDirectory) : undefined; } storeNewSubdirectory(parent, sourceName, destination) { const source = new Subdirectory(parent.source, sourceName); const newMappedSubdirectory = new MappedSubdirectory(parent, source, destination, ""); parent.add(newMappedSubdirectory); return newMappedSubdirectory; } zipFile(parentDirectory, source) { return __awaiter(this, void 0, void 0, function* () { const paths = this.getSourceAndDestinationPaths(parentDirectory, source, ".7z"); this.print(`+ ${paths.source.relativePath}`); const pathInfo = this.getLogFilePathInfo("cp", paths.destination.absolutePath, paths.source.absolutePath); let success = true; if (this.isDryRun) { this.logger.info(`Would zip ${pathInfo}`); } else { this.logger.info(`Zipping ${pathInfo}`); success = yield this.zipFileAndLogErrors(pathInfo, paths.source.relativePath, paths.destination.absolutePath); } return success ? this.storeNewFile(parentDirectory, source.name, paths.destination.filename) : undefined; }); } zipFileAndLogErrors(pathInfo, sourceRelativePath, destinationAbsolutePath) { return __awaiter(this, void 0, void 0, function* () { try { const result = yield this.context.sevenZip.zipFile(this.database.source.absolutePath, sourceRelativePath, destinationAbsolutePath); if (!result.success) { this.logger.error(result.consoleOutput); this.logger.error(`Failed to zip ${pathInfo}: ${result.errorMessage}`); this.print(FileManager.LOG_MESSAGE_FAILED); } return result.success; } catch (exception) { this.logger.error(`Failed to zip ${pathInfo} - ${firstLineOnly(exception)}`); this.print(FileManager.LOG_MESSAGE_FAILED); return false; } }); } storeNewFile(parent, sourceName, destinationName) { const properties = FileUtils.getProperties(node.path.join(parent.source.absolutePath, sourceName)); const newMappedFile = new MappedFile(parent, new File(parent.source, sourceName), new File(parent.destination, destinationName), properties.birthtimeMs, properties.ctimeMs, properties.size); parent.add(newMappedFile); return newMappedFile; } deleteFile(options) { const isMetadataArchive = !options.source && this.database.destination.absolutePath === node.path.dirname(options.destination) && MetadataManager.isMetadataArchiveName(node.path.basename(options.destination)); return this.deleteFileOrDirectory(Object.assign(Object.assign({}, options), { type: "file", isMetadataArchive: isMetadataArchive, suppressConsoleOutput: options.suppressConsoleOutput || isMetadataArchive })); } deleteDirectory(options) { return this.deleteFileOrDirectory(Object.assign(Object.assign({}, options), { type: "directory" })); } deleteFileOrDirectory(options) { const isOrphan = undefined !== options.orphanDisplayPath; if (!options.suppressConsoleOutput) { if (options.orphanDisplayPath) { this.print(`- ${options.orphanDisplayPath} (orphan)`); } else { this.print(`- ${this.getConsolePathInfo("rm", options.destination, options.source)}`); } } const pathInfo = this.getLogFilePathInfo("rm", options.destination, options.source); const reason = options.reason ? ` ${options.reason}` : ""; if (isOrphan && !options.isMetadataArchive) { this.logger.warn(this.isDryRun ? `Would delete orphaned ${options.type} ${pathInfo}${reason}` : `Deleting orphaned ${options.type} ${pathInfo}${reason}`); } else { this.logger.info(this.isDryRun ? `Would delete ${options.type} ${pathInfo}${reason}` : `Deleting ${options.type} ${pathInfo}${reason}`); } const success = this.doDeleteFileOrDirectory(options.destination, "directory" === options.type); if (!success && !options.suppressConsoleOutput) { this.print(FileManager.LOG_MESSAGE_FAILED); } return success; } doDeleteFileOrDirectory(path, isDirectory) { if (!this.isDryRun) { try { node.fs.rmSync(path, isDirectory ? { recursive: true, force: true } : {}); if (FileUtils.exists(path)) { throw new FriendlyException("No exception was raised but the file is still present"); } } catch (exception) { this.logger.error(`Failed to delete ${path} - ${firstLineOnly(exception)}`); return false; } } return true; } getSourceAndDestinationPaths(directory, source, suffix) { const sourceAbsolute = node.path.join(directory.source.absolutePath, source.name); const sourceRelative = node.path.relative(this.database.source.absolutePath, sourceAbsolute); const next = this.reserveNextAvailableFilename(directory, "", suffix); const destinationAbsolute = node.path.join(directory.destination.absolutePath, next.filename); const destinationRelative = node.path.relative(this.database.destination.absolutePath, destinationAbsolute); return { source: { absolutePath: sourceAbsolute, relativePath: sourceRelative }, destination: { filename: next.filename, absolutePath: destinationAbsolute, relativePath: destinationRelative } }; } getLogFilePathInfo(operation, destinationPath, sourcePath) { if ("cp" === operation) { return sourcePath ? `${sourcePath} => ${destinationPath}` : destinationPath; } else { return sourcePath ? `${destinationPath} (mirroring ${sourcePath})` : destinationPath; } } getConsolePathInfo(operation, destinationPath, sourcePath) { const source = sourcePath ? node.path.relative(this.database.source.absolutePath, sourcePath) : undefined; const destination = node.path.relative(this.database.destination.absolutePath, destinationPath); if ("rm" === operation) { return source !== null && source !== void 0 ? source : `${destination} (orphan)`; } else { return source; } } reserveNextAvailableFilename(directory, prefix, suffix) { const next = this.context.filenameEnumerator.getNextAvailableFilename(directory.destination.absolutePath, directory.last, prefix, suffix); directory.last = next.enumeratedName; return next; } } FileManager.LOG_MESSAGE_FAILED = "===> FAILED"; class FilenameEnumerator { constructor(logger) { this.logger = logger; const uniqueLetters = FilenameEnumerator.getUniqueLetters(FilenameEnumerator.LETTERS); const array = uniqueLetters.array; this.allLetters = uniqueLetters.set; if (0 < array.length) { this.firstLetter = array[0]; this.nextLetter = FilenameEnumerator.getNextLetterMap(array); this.letterToIndex = FilenameEnumerator.getLetterToIndexMap(array); } else { throw new Error("Internal error: No letters have been passed to the FilenameEnumerator"); } } static getUniqueLetters(letters) { const set = new Set(); const array = letters.split("") .filter(letter => !letter.match(/\s/)) .filter(letter => { if (set.has(letter)) { return false; } else { set.add(letter); return true; } }); return { set, array }; } static getNextLetterMap(letters) { const map = new Map(); for (let index = 1; index < letters.length; index++) { map.set(letters[index - 1], letters[index]); } return map; } static getLetterToIndexMap(letters) { const map = new Map(); for (let index = 0; index < letters.length; index++) { map.set(letters[index], index); } return map; } calculateNext(last) { const array = last.split(""); for (let index = array.length - 1; 0 <= index; index--) { const nextLetter = this.nextLetter.get(array[index]); if (nextLetter) { array[index] = nextLetter; return array.join(""); } else { array[index] = this.firstLetter; } } return array.join("") + this.firstLetter; } getNextAvailableFilename(path, last, prefix, suffix) { let next = last; while (true) { next = next ? this.calculateNext(next) : this.firstLetter; const filename = prefix + next + suffix; const filenameWithPath = node.path.join(path, filename); if (!FilenameEnumerator.RESERVED_NAMES.has(next.toLowerCase())) { if (FileUtils.exists(filenameWithPath)) { this.logger.warn(`The next filename is already occupied: ${path} => ${filename}`);