/// <summary> /// Creates a new randomly-named backup directory in the given directory. Creates the given directory too, if /// required. /// </summary> /// <param name="targetPath">The path of the directory in which to create the backup directory.</param> /// <returns>The name of the new backup directory.</returns> /// <exception cref="BackupDirectoryCreateException">If the new directory could not be created, due to I/O /// errors, permission errors, etc.</exception> public static string CreateBackupDirectory(string targetPath) { var retries = BACKUP_DIRECTORY_CREATION_RETRIES; List <string> attemptedDirectoryNames = new(); while (true) { var name = Utility.RandomAlphaNumericString(BACKUP_DIRECTORY_NAME_LENGTH); attemptedDirectoryNames.Add(name); var path = BackupPath(targetPath, name); FilesystemException?exception = null; // Non-atomicity :| if (!Directory.Exists(path) && !File.Exists(path)) { try { FilesystemException.ConvertSystemException(() => Directory.CreateDirectory(path), () => path); return(name); } catch (FilesystemException e) { exception = e; } } if (retries <= 0) { throw new BackupDirectoryCreateException(targetPath, attemptedDirectoryNames, exception); } retries--; } }
/// <summary> /// Parses and validates the application's command line arguments. /// </summary> /// <remarks> /// Note that the filesystem paths in the returned <see cref="AppConfig"/> are not guaranteed to be valid, as /// this is unfortunately not really possible to check without actually doing the desired I/O operation. <br/> /// However, some invalid paths are detected by this method. /// </remarks> /// <param name="args">The command line arguments.</param> /// <returns>The <see cref="AppConfig"/> parsed from the arguments.</returns> /// <exception cref="InvalidCmdArgsError">If the command line arguments are invalid.</exception> /// <exception cref="ApplicationRuntimeError">If the config paths can't be resolved.</exception> private AppConfig ParseCmdArgs(string[] args) { if (args.Length < 2) { throw new InvalidCmdArgsError(); } var sourcePath = args[0]; var targetPath = args[1]; var excludePaths = args.Skip(2); try { sourcePath = FilesystemException.ConvertSystemException(() => Path.GetFullPath(sourcePath), () => sourcePath); } catch (FilesystemException e) { throw new ApplicationRuntimeError($"Failed to resolve source directory: {e.Reason}"); } if (File.Exists(sourcePath)) { throw new ApplicationRuntimeError("Source directory is not a directory"); } if (!Directory.Exists(sourcePath)) { throw new ApplicationRuntimeError("Source directory not found"); } try { targetPath = FilesystemException.ConvertSystemException(() => Path.GetFullPath(targetPath), () => targetPath); } catch (FilesystemException e) { throw new ApplicationRuntimeError($"Failed to resolve target directory: {e.Reason}"); } if (File.Exists(targetPath)) { throw new ApplicationRuntimeError("Target directory exists and is not a directory."); } List <string> parsedExcludePaths = new(); foreach (var path in excludePaths) { string fullPath; try { fullPath = FilesystemException.ConvertSystemException( () => Path.GetFullPath(path, sourcePath), () => path); } catch (FilesystemException e) { Logger.Warning($"Failed to resolve exclude path \"{path}\" ({e.Reason}); discarding."); continue; } parsedExcludePaths.Add(fullPath); } return(new(sourcePath, targetPath, parsedExcludePaths)); }
/// <summary> /// Opens a backup index file for reading. /// </summary> /// <param name="filePath">The path to the index file.</param> /// <returns>A new <see cref="StreamReader"/> associated with the file.</returns> /// <exception cref="BackupIndexFileIOException">If the file could not be opened.</exception> private static StreamReader OpenFile(string filePath) { try { return(FilesystemException.ConvertSystemException( () => new StreamReader(filePath, new UTF8Encoding(false, true)), () => filePath)); } catch (FilesystemException e) { throw new BackupIndexFileIOException(filePath, e); } }
/// <summary> /// Creates a handler that logs to a file at the given path. <br/> /// The file is created new or overwritten if it exists. /// </summary> /// <param name="path">The file to log messages to.</param> /// <exception cref="LoggingException">If the file could not be opened.</exception> public FileLogHandler(string path) { try { Stream = FilesystemException.ConvertSystemException(() => File.CreateText(path), () => path); } catch (FilesystemException e) { throw new LoggingException($"Failed to create/open log file \"{path}\": {e.Reason}", e); } FilePath = path; }
/// <summary> /// Logs a message to the associated file. /// </summary> /// <param name="level">The level of the message.</param> /// <param name="message">The message to log.</param> /// <exception cref="LoggingException">If the message could not be written to the file due to I/O errors. /// </exception> public void Log(LogLevel level, string message) { var formattedMessage = LogFormatter.FormatMessage(level, message); try { FilesystemException.ConvertSystemException(() => { Stream.Write(formattedMessage); Stream.Flush(); }, () => FilePath); } catch (FilesystemException e) { throw new LoggingException($"Failed to log to file \"{FilePath}\": {e.Reason}", e); } }
/// <summary> /// Reads a backup index from file. /// </summary> /// <param name="filePath">The path to the index file.</param> /// <returns>The read backup index.</returns> /// <exception cref="BackupIndexFileIOException">If a filesystem error occurs during reading.</exception> /// <exception cref="BackupIndexFileParseException">If the file is not a valid backup index.</exception> public static BackupIndex Read(string filePath) { using var stream = OpenFile(filePath); BackupIndex index = new(); long lineNum = 0; while (true) { string?line; try { line = FilesystemException.ConvertSystemException(() => stream.ReadLine(), () => filePath); } catch (FilesystemException e) { throw new BackupIndexFileIOException(filePath, e); } if (line is null) { break; } lineNum++; // Empty line ok, just skip it. if (line.Length == 0) { continue; } // Split on first separator (should be exactly 1). var parts = line.Split(BackupIndexFileConstants.SEPARATOR, 2); if (parts.Length != 2) { throw new BackupIndexFileParseException(filePath, lineNum); } // Note that empty values are valid, even though that shouldn't happen in practice. var backupDirectory = parts[0]; var backupSourcePath = Utility.NewlineDecode(parts[1]); index.Backups[backupDirectory] = backupSourcePath; } return(index); }
/// <summary> /// Performs the back up. <br/> /// Should not be called more than once per instance. /// </summary> /// <returns>The results of the backup.</returns> /// <exception cref="BackupServiceException">If the source directory can't be accessed.</exception> private BackupResults Run() { // Explore directories in a depth-first manner, on the basis that files/directories within the same branch // of the filesystem are likely to be modified together, so we want to back them up as close together in // time as possible. It's also probably more useful to have some directories fully backed up rather than // all directories partially backed up (if using breadth-first), in the case the backup is stopped early. SearchState searchState = new(true, new(1000), new(20)); try { var root = FilesystemException.ConvertSystemException(() => new DirectoryInfo(SourcePath), () => SourcePath); searchState.NodeStack.Add(state => VisitDirectory(state, root)); } catch (FilesystemException e) { throw new BackupServiceException($"Failed to enumerate source directory \"{SourcePath}\": {e.Reason}", e); } do { var currentNode = searchState.NodeStack[^ 1];
/// <summary> /// Writes a new backup entry to a backup index file. /// </summary> /// <param name="indexFilePath">The path of the index file to write to. May be a nonexistent file, in which /// case it will be created.</param> /// <param name="backupName">The name of the backup directory. Must not contain /// <see cref="BackupIndexFileConstants.SEPARATOR"/> or newline characters.</param> /// <param name="backupSourcePath">The path of the source directory for the backup.</param> /// <exception cref="ArgumentException">If <paramref name="backupName"/> contains invalid characters. /// </exception> /// <exception cref="BackupIndexFileIOException">Failed to write to the index file due to filesystem-related /// errors.</exception> public static void AddEntry(string indexFilePath, string backupName, string backupSourcePath) { if (backupName.Contains(BackupIndexFileConstants.SEPARATOR)) { throw new ArgumentException( $"{nameof(backupName)} must not contain {BackupIndexFileConstants.SEPARATOR}", nameof(backupName)); } if (Utility.ContainsNewlines(backupName)) { throw new ArgumentException($"{nameof(backupName)} must not contain newlines.", nameof(backupName)); } var entry = $"{backupName}{BackupIndexFileConstants.SEPARATOR}{Utility.NewlineEncode(backupSourcePath)}\n"; try { FilesystemException.ConvertSystemException( () => File.AppendAllText(indexFilePath, entry, new UTF8Encoding(false, true)), () => indexFilePath); } catch (FilesystemException e) { throw new BackupIndexFileIOException(indexFilePath, e); } }
public BackupIndexFileIOException(string filePath, FilesystemException innerException) : base(filePath, $"Failed to access backup index file \"{filePath}\": {innerException.Reason}", innerException) { }