Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Parallel.Cli/Commands/ConfigCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ namespace Parallel.Cli.Commands
{
public class ConfigCommand : Command
{
private Option<string> configOpt = new(["--config", "-c"], "The profile configuration to use.");

private Command addCmd = new("add", "Adds a new profile configuration.");
private Command editCmd = new("edit", "Edits a profile configuration.");
private Command viewCmd = new("view", "Shows the profile configuration.");
Expand Down
70 changes: 70 additions & 0 deletions Parallel.Cli/Commands/DecryptCommand.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,84 @@
// Copyright 2025 Kyle Ebbinga

using System.CommandLine;
using Parallel.Cli.Utils;
using Parallel.Core.Database;
using Parallel.Core.IO;
using Parallel.Core.Models;
using Parallel.Core.Settings;
using Parallel.Core.Utils;

namespace Parallel.Cli.Commands
{
public class DecryptCommand : Command
{
private readonly Argument<string> _sourceArg = new("path", "The source path of files to zip.");
private readonly Option<string> _configOpt = new(["--config", "-c"], "The profile configuration to use.");

private IDatabase? _database;

public DecryptCommand() : base("decrypt", "Decrypts a file or directory.")
{
this.AddArgument(_sourceArg);
this.SetHandler(async (path, config) =>
{
ProfileConfig? profile = ProfileConfig.Load(Program.Settings, config);
if (profile == null)
{
CommandLine.WriteLine("No active profile was found!", ConsoleColor.Yellow);
return;
}

_database = DatabaseConnection.CreateNew(profile);
string masterKey = profile.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!");
if (PathBuilder.IsDirectory(path))
{
await DecryptDirectoryAsync(path, masterKey);
}
else if (PathBuilder.IsFile(path))
{
CommandLine.WriteLine($"Decrypting {path}...", ConsoleColor.DarkGray);
await DecryptFileAsync(path, masterKey);

CommandLine.WriteLine($"Successfully decrypted file: {path}", ConsoleColor.Green);
}
else
{
CommandLine.WriteLine("The specified path is invalid.", ConsoleColor.Red);
}
}, _sourceArg, _configOpt);
}

private Task DecryptDirectoryAsync(string path, string masterKey)
{
CommandLine.WriteLine($"Scanning for files in {path}...", ConsoleColor.DarkGray);
return Task.CompletedTask;
}

private async Task DecryptFileAsync(string path, string masterKey)
{
SystemFile systemFile = await _database?.GetFileAsync(path)! ?? new SystemFile(path);
if (File.Exists(systemFile.LocalPath) && systemFile.Encrypted)
{
string tempFile = Path.Combine(PathBuilder.TempDirectory, Path.GetFileName(systemFile.LocalPath)) + ".tmp";
CommandLine.WriteLine($"Writing to {tempFile}", ConsoleColor.DarkGray);

await using (FileStream openFile = new FileStream(systemFile.LocalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
await using (FileStream createFile = new FileStream(tempFile, FileMode.OpenOrCreate))
{
SystemFile result = Encryption.DecryptStream(openFile, createFile, systemFile, masterKey);
if (!await _database?.AddFileAsync(result)!)
{
CommandLine.WriteLine($"Failed to decrypt file: {systemFile.LocalPath}", ConsoleColor.Red);
if(File.Exists(tempFile)) File.Delete(tempFile);
}
}

File.Copy(tempFile, systemFile.LocalPath, true);
//if(File.Exists(tempFile)) File.Delete(tempFile);
}

//CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw.Elapsed, ConsoleColor.DarkGray);
}
}
}
54 changes: 54 additions & 0 deletions Parallel.Cli/Commands/DuplicatesCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright 2025 Kyle Ebbinga

using System.CommandLine;
using Parallel.Cli.Utils;
using Parallel.Core.IO.Backup;
using Parallel.Core.IO.Scanning;
using Parallel.Core.Models;
using Parallel.Core.Settings;

using TextWriter = Parallel.Cli.Utils.TextWriter;

namespace Parallel.Cli.Commands
{
public class DuplicatesCommand : Command
{
private Argument<string> sourceArg = new("path", "The directory to scan.");
private Option<string> credsOpt = new(["--credentials", "-c"], "The file system credentials to use.");

public DuplicatesCommand() : base("duplicates", "Scans a directory for duplicate files.")
{
this.AddArgument(sourceArg);
this.AddOption(credsOpt);
this.SetHandler((path, config) =>
{
ProfileConfig profile = ProfileConfig.Load(Program.Settings, config);
ScanForDuplicateFiles(path, profile);
}, sourceArg, credsOpt);
}

private void ScanForDuplicateFiles(string path, ProfileConfig profile)
{
IBackupManager backup = BackupManager.CreateNew(profile);
if (!backup.Initialize())
{
CommandLine.WriteLine("Failed to connect to backup file system!", ConsoleColor.Red);
return;
}

if (!Directory.Exists(path))
{
CommandLine.WriteLine("The provided directory is invalid!", ConsoleColor.Yellow);
return;
}

CommandLine.WriteLine($"Scanning for duplicate files in {path}...", ConsoleColor.DarkGray);
Dictionary<string, SystemFile[]> duplicates = FileScanner.GetDuplicateFiles(path);
Dictionary<string, string[]> result = duplicates.ToDictionary(k => k.Key, v => v.Value.Select(l => l.LocalPath).ToArray());
long length = duplicates.Sum(kv => kv.Value.Sum(l => l.LocalSize));

CommandLine.WriteLine($"Scan found {duplicates.Where(kv => kv.Value.Length > 1).Count().ToString("N0")} duplicate files. ({Formatter.FromBytes(length)})");
CommandLine.WriteLine($"A detailed version was created here: {TextWriter.CreateTxtFile(JsonConvert.SerializeObject(result, Formatting.Indented))}");
}
}
}
72 changes: 71 additions & 1 deletion Parallel.Cli/Commands/EncryptCommand.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,86 @@
// Copyright 2025 Kyle Ebbinga

using System.CommandLine;
using System.IO.Compression;
using Parallel.Cli.Utils;
using Parallel.Core.Database;
using Parallel.Core.IO;
using Parallel.Core.Models;
using Parallel.Core.Settings;
using Parallel.Core.Utils;

namespace Parallel.Cli.Commands
{
public class EncryptCommand : Command
{
private readonly Argument<string> sourceArg = new("path", "The source path to encrypt.");
private readonly Argument<string> _sourceArg = new("path", "The source path to encrypt.");
private readonly Option<string> _configOpt = new(["--config", "-c"], "The profile configuration to use.");

private IDatabase? _database;

public EncryptCommand() : base("encrypt", "Encrypts a file or directory.")
{
this.AddArgument(_sourceArg);
this.SetHandler(async (path, config) =>
{
ProfileConfig? profile = ProfileConfig.Load(Program.Settings, config);
if (profile == null)
{
CommandLine.WriteLine("No active profile was found!", ConsoleColor.Yellow);
return;
}

_database = DatabaseConnection.CreateNew(profile);
string masterKey = profile.FileSystem.EncryptionKey ?? throw new ArgumentException("No encryption key provided!");
if (PathBuilder.IsDirectory(path))
{
await EncryptDirectoryAsync(path, masterKey);
}
else if (PathBuilder.IsFile(path))
{
CommandLine.WriteLine($"Encrypting {path}...", ConsoleColor.DarkGray);
await EncryptFileAsync(path, masterKey);

CommandLine.WriteLine($"Successfully encrypted file: {path}", ConsoleColor.Green);
}
else
{
CommandLine.WriteLine("The specified path is invalid.", ConsoleColor.Red);
}

}, _sourceArg, _configOpt);
}

private Task EncryptDirectoryAsync(string path, string masterKey)
{
CommandLine.WriteLine($"Scanning for files in {path}...", ConsoleColor.DarkGray);
return Task.CompletedTask;
}

private async Task EncryptFileAsync(string path, string masterKey)
{
SystemFile systemFile = await _database?.GetFileAsync(path)! ?? new SystemFile(path);
if (File.Exists(systemFile.LocalPath) && !systemFile.Encrypted)
{
string tempFile = Path.Combine(PathBuilder.TempDirectory, Path.GetFileName(systemFile.LocalPath)) + ".tmp";
CommandLine.WriteLine($"Writing to {tempFile}", ConsoleColor.DarkGray);

await using (FileStream openFile = new FileStream(systemFile.LocalPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
await using (FileStream createFile = new FileStream(tempFile, FileMode.OpenOrCreate))
{
SystemFile result = Encryption.EncryptStream(openFile, createFile, systemFile, masterKey);
if (!await _database?.AddFileAsync(result)!)
{
CommandLine.WriteLine($"Failed to decrypt file: {systemFile.LocalPath}", ConsoleColor.Red);
if(File.Exists(tempFile)) File.Delete(tempFile);
}
}

File.Copy(tempFile, systemFile.LocalPath, true);
//if(File.Exists(tempFile)) File.Delete(tempFile);
}

//CommandLine.ProgressBar(_tasks.Count(t => t.IsCompleted), _totalTasks, _sw.Elapsed, ConsoleColor.DarkGray);
}
}
}
20 changes: 17 additions & 3 deletions Parallel.Core/Database/Contexts/SqliteContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,43 +29,57 @@ public SqliteContext(DatabaseCredentials credentials, string profileId)

#region Base

/// <inheritdoc />
public IDbConnection CreateConnection()
{
return new SqliteConnection("Data Source=" + FilePath);
}

/// <inheritdoc />
public async Task InitializeAsync()
{
Log.Information("Creating local database...");
File.Create(FilePath).Close();
File.SetAttributes(FilePath, File.GetAttributes(FilePath) | FileAttributes.Hidden);

using IDbConnection connection = CreateConnection();
await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`profile` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `path` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `encrypted` INTEGER NOT NULL DEFAULT 0, `salt` BLOB NOT NULL, `iv` BLOB NOT NULL, PRIMARY KEY(`profile`, `id`));");
await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `files` (`profile` TEXT NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `localpath` TEXT NOT NULL, `remotepath` TEXT NOT NULL, `lastwrite` LONG INTEGER NOT NULL, `lastupdate` LONG INTEGER NOT NULL, `localsize` LONG INTEGER NOT NULL, `remotesize` LONG INTEGER NOT NULL, `type` TEXT NOT NULL DEFAULT Other CHECK(`type` IN ('Document', 'Photo', 'Music', 'Video', 'Other')), `hidden` INTEGER NOT NULL DEFAULT 0, `readonly` INTEGER NOT NULL DEFAULT 0, `deleted` INTEGER NOT NULL DEFAULT 0, `encrypted` INTEGER NOT NULL DEFAULT 0, `salt` BLOB, `iv` BLOB, PRIMARY KEY(`profile`, `id`));");
await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS `history` (`profile` TEXT NOT NULL, `timestamp` LONG INTEGER NOT NULL, `path` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`profile`, `timestamp`));");
}

#endregion

#region Files

/// <inheritdoc />
public async Task<bool> AddFileAsync(SystemFile file)
{
using IDbConnection connection = CreateConnection();
string sql = @"INSERT OR REPLACE INTO files (profile, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted);";
return await connection.ExecuteAsync(sql, new { ProfileId, file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = file.LastUpdate.TotalMilliseconds, file.LocalSize, file.RemoteSize, }) > 0;
string sql = @"INSERT OR REPLACE INTO files (profile, id, name, localpath, remotepath, lastwrite, lastupdate, LocalSize, RemoteSize, type, hidden, readonly, deleted, encrypted, salt, iv) VALUES (@ProfileId, @Id, @Name, @LocalPath, @RemotePath, @LastWrite, @LastUpdate, @LocalSize, @RemoteSize, @Type, @Hidden, @ReadOnly, @Deleted, @Encrypted, @Salt, @IV);";
return await connection.ExecuteAsync(sql, new { ProfileId, file.Id, file.Name, file.LocalPath, file.RemotePath, LastWrite = file.LastWrite.TotalMilliseconds, LastUpdate = UnixTime.Now.TotalMilliseconds, file.LocalSize, file.RemoteSize, Type = file.Type.ToString(), file.Hidden, file.ReadOnly, file.Deleted, file.Encrypted, file.Salt, file.IV }) > 0;
}

/// <inheritdoc />
public async Task<IEnumerable<SystemFile>> GetFilesAsync(string path, bool deleted)
{
using IDbConnection connection = CreateConnection();
string sql = $"SELECT * FROM files WHERE profile = \"{ProfileId}\" AND deleted = {deleted} ORDER BY lastupdate DESC";
return await connection.QueryAsync<SystemFile>(sql);
}

/// <inheritdoc />
public async Task<SystemFile?> GetFileAsync(string path)
{
using IDbConnection connection = CreateConnection();
string sql = $"SELECT * FROM files WHERE profile = \"{ProfileId}\" AND localpath LIKE \"%{path}%\" OR remotepath LIKE \"%{path}%\" ORDER BY lastupdate DESC";
return await connection.QuerySingleOrDefaultAsync<SystemFile>(sql);
}

#endregion

#region History

/// <inheritdoc />
public async Task<bool> AddHistoryAsync(string path, HistoryType type)
{
using IDbConnection connection = CreateConnection();
Expand Down
12 changes: 12 additions & 0 deletions Parallel.Core/Database/IDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,28 @@ public interface IDatabase

#region Files

/// <summary>
/// Adds a new file or updates an existing one.
/// </summary>
/// <param name="file"></param>
/// <returns>True if successful, false otherwise</returns>
Task<bool> AddFileAsync(SystemFile file);

#endregion

#region History

/// <summary>
/// Adds a new history.
/// </summary>
/// <param name="path"></param>
/// <param name="type"></param>
/// <returns>True if successful, false otherwise</returns>
Task<bool> AddHistoryAsync(string path, HistoryType type);

#endregion

Task<IEnumerable<SystemFile>> GetFilesAsync(string path, bool b);
Task<SystemFile?> GetFileAsync(string path);
}
}
2 changes: 1 addition & 1 deletion Parallel.Core/Events/LocalFileEventArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class LocalFileEventArgs : EventArgs

public LocalFileEventArgs(string file)
{
SystemFile = new SystemFile(new FileInfo(file));
SystemFile = new SystemFile(file);
}
}
}
6 changes: 6 additions & 0 deletions Parallel.Core/IO/Backup/BaseFileManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
///
/// </summary>
/// <param name="profile"></param>
public BaseFileManager(ProfileConfig profile)

Check warning on line 36 in Parallel.Core/IO/Backup/BaseFileManager.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Non-nullable property 'Database' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
{
FileSystem = FileSystemManager.CreateNew(profile.FileSystem);
Profile = profile;
Expand Down Expand Up @@ -62,5 +62,11 @@

/// <inheritdoc />
public abstract Task RestoreFilesAsync(SystemFile[] files, IProgressReporter progress);

/// <inheritdoc />
public abstract Task DecryptFilesAsync(SystemFile[] files, IProgressReporter progress);

/// <inheritdoc />
public abstract Task EncryptFilesAsync(SystemFile[] files, IProgressReporter progress);
}
}
12 changes: 12 additions & 0 deletions Parallel.Core/IO/Backup/DeltaBackupManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,17 @@ public override Task RestoreFilesAsync(SystemFile[] files, IProgressReporter pro
{
throw new NotImplementedException();
}

/// <inheritdoc />
public override Task DecryptFilesAsync(SystemFile[] files, IProgressReporter progress)
{
throw new NotImplementedException();
}

/// <inheritdoc />
public override Task EncryptFilesAsync(SystemFile[] files, IProgressReporter progress)
{
throw new NotImplementedException();
}
}
}
12 changes: 12 additions & 0 deletions Parallel.Core/IO/Backup/FileBackupManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,17 @@ public override async Task RestoreFilesAsync(SystemFile[] files, IProgressReport
file.RemotePath = PathBuilder.Remote(file.LocalPath, Profile.FileSystem);
}
}

/// <inheritdoc />
public override Task DecryptFilesAsync(SystemFile[] files, IProgressReporter progress)
{
throw new NotImplementedException();
}

/// <inheritdoc />
public override Task EncryptFilesAsync(SystemFile[] files, IProgressReporter progress)
{
throw new NotImplementedException();
}
}
}
Loading
Loading