using System.Diagnostics;
using System.Security.Cryptography;
using Packwright.App.Services;
using Packwright.Containers;
using Packwright.Core.Services;
using Packwright.Core.Tasks;
using UFS2Tool;
namespace Packwright.SmokeTests;
///
/// Edit operations on every format: editing files inside exFAT and FFPKG images, writing edited details into
/// every format (folder, four images, package), and the repair / refresh / rebuild maintenance jobs.
/// Usage: SmokeTests edit-tests <package> <reference manifest> <packwright.cmd> <work folder>
///
internal static class EditTests
{
private static string _cli = string.Empty;
private static Dictionary _ref = new();
private static int _failures;
public static async Task Run(string[] args)
{
string pkg = args[1], manifest = args[2];
_cli = args[3];
string work = Path.GetFullPath(args[4]);
if (Directory.Exists(work)) Directory.Delete(work, true);
Directory.CreateDirectory(work);
foreach (string line in File.ReadLines(manifest))
{
string[] p = line.Split('|');
if (p.Length == 3) _ref[p[0]] = (long.Parse(p[1]), p[2]);
}
string[] candidates = _ref.Where(f => !f.Key.StartsWith("sce_sys/", StringComparison.OrdinalIgnoreCase))
.OrderBy(f => f.Value.Size).Select(f => f.Key).ToArray();
string fileA = candidates[0], fileB = candidates[1];
Console.WriteLine($"reference: {_ref.Count} files; edit targets: replace '{fileA}', delete '{fileB}'");
Console.WriteLine("\n== 1. editing files inside images");
foreach (string fmt in new[] { "exfat", "ffpkg" })
await FileEditsAsync(pkg, work, fmt, fileA, fileB);
Console.WriteLine("\n== 2. writing edited details into every format");
foreach (string fmt in new[] { "folder", "exfat", "ffpkg", "ffpfsc", "zar", "pkg" })
await DetailsAsync(pkg, work, fmt);
Console.WriteLine("\n== 3. maintenance jobs");
await MaintenanceAsync(pkg, work);
Console.WriteLine($"\nEDIT TESTS DONE, {_failures} failure(s)");
return _failures == 0 ? 0 : 1;
}
// ---------------------------------------------------------------------------------------------
private static async Task FileEditsAsync(string pkg, string work, string fmt, string fileA, string fileB)
{
string label = $"edit files in {fmt}";
try
{
string image = Path.Combine(work, "fe." + fmt);
Cli($"convert \"{pkg}\" --to {fmt} -o \"{image}\"");
string replacement = Path.Combine(work, "replacement.bin");
File.WriteAllText(replacement, "REPLACED-CONTENT-" + fmt);
string added = Path.Combine(work, "added.txt");
File.WriteAllText(added, "ADDED-FILE-" + fmt);
string tree = Path.Combine(work, "tree");
Directory.CreateDirectory(Path.Combine(tree, "sub"));
File.WriteAllText(Path.Combine(tree, "one.txt"), "tree-one");
File.WriteAllText(Path.Combine(tree, "sub", "two.txt"), "tree-two");
if (fmt == "exfat")
await ExfatImageMaintenance.ApplyEditsAsync(image, new[]
{
ExfatEditOperation.Replace(fileA, replacement),
ExfatEditOperation.AddFile("added/new.txt", added),
ExfatEditOperation.AddDirectoryTree("added/tree", tree),
ExfatEditOperation.AddDirectory("added/emptydir"),
ExfatEditOperation.Delete(fileB),
});
else
await Ufs2Operations.ApplyEditsAsync(image, new[]
{
Ufs2EditOperation.Replace(fileA, replacement),
Ufs2EditOperation.AddFile("added/new.txt", added),
Ufs2EditOperation.AddDirectoryTree("added/tree", tree),
Ufs2EditOperation.AddDirectory("added/emptydir"),
Ufs2EditOperation.Delete(fileB),
});
if (Cli($"verify \"{image}\"") != 0) { Fail(label, "verify failed after the edits"); return; }
string outDir = Path.Combine(work, "fe-out");
if (Cli($"extract \"{image}\" -o \"{outDir}\"") != 0) { Fail(label, "extract failed"); return; }
var problems = new List();
if (Read(outDir, fileA) != "REPLACED-CONTENT-" + fmt) problems.Add("replaced file has the wrong content");
if (Read(outDir, "added/new.txt") != "ADDED-FILE-" + fmt) problems.Add("added file is wrong or missing");
if (Read(outDir, "added/tree/one.txt") != "tree-one" || Read(outDir, "added/tree/sub/two.txt") != "tree-two")
problems.Add("added folder tree is wrong or missing");
if (!Directory.Exists(Path.Combine(outDir, "added", "emptydir"))) problems.Add("new empty folder is missing");
if (File.Exists(Path.Combine(outDir, fileB.Replace('/', Path.DirectorySeparatorChar)))) problems.Add("deleted file is still there");
(int ok, int bad, int missing) = CompareTree(outDir, skip: [fileA, fileB]);
if (bad > 0 || missing > 0) problems.Add($"{bad} other files changed, {missing} went missing");
Report(label, problems, $"{ok} untouched files identical");
Directory.Delete(outDir, true);
File.Delete(image);
Directory.Delete(tree, true);
}
catch (Exception ex) { Fail(label, ex.GetType().Name + ": " + ex.Message); }
}
// ---------------------------------------------------------------------------------------------
private static async Task DetailsAsync(string pkg, string work, string fmt)
{
string label = $"edit details of {fmt}";
string source = Path.Combine(work, "ed." + fmt);
try
{
string output = string.Empty;
switch (fmt)
{
case "pkg":
source = Path.Combine(work, "ed-source.pkg");
File.Copy(pkg, source, true);
output = Path.Combine(work, "ed-edited.pkg");
break;
case "folder":
string baseImage = Path.Combine(work, "ed-base.exfat");
Cli($"convert \"{pkg}\" --to exfat -o \"{baseImage}\"");
Cli($"extract \"{baseImage}\" -o \"{source}\"");
File.Delete(baseImage);
break;
default:
Cli($"convert \"{pkg}\" --to {fmt} -o \"{source}\"");
break;
}
var edit = new MetadataEdit { Title = "Edited Title QA", Version = "01.77.000" };
var progress = new Progress(_ => { });
await MetadataEditor.ApplyAsync(source, output, fromPackage: fmt == "pkg", edit,
"00000000000000000000000000000000", "ppt", progress, CancellationToken.None);
string edited = fmt == "pkg" ? output : source;
var problems = new List();
var scan = await new Ps5LibraryScanner().ScanAsync([fmt == "folder" ? work : edited], recursive: true);
var game = scan.Games.FirstOrDefault(g => g.RootPath.StartsWith(fmt == "folder" ? source : edited, StringComparison.OrdinalIgnoreCase));
if (game is null) problems.Add("the edited file is not found by the scanner");
else
{
if (game.Title != "Edited Title QA") problems.Add($"title is '{game.Title}'");
if (!game.DisplayVersion.Contains("77")) problems.Add($"version is '{game.DisplayVersion}'");
}
if (fmt != "folder" && Cli($"verify \"{edited}\"") != 0) problems.Add("verify failed after the edit");
string detail = "title and version changed";
if (fmt != "pkg" || problems.Count == 0)
{
string outDir = Path.Combine(work, "ed-out");
if (fmt == "folder") { Report(label, problems, detail); Directory.Delete(source, true); return; }
if (Cli($"extract \"{edited}\" -o \"{outDir}\"") != 0) problems.Add("extract failed");
else
{
// everything except the rewritten param.json must be untouched
(int ok, int bad, int missing) = CompareTree(outDir, skip: ["sce_sys/param.json"]);
if (bad > 0) problems.Add($"{bad} other files changed");
detail += $", {ok} other files identical";
Directory.Delete(outDir, true);
}
}
Report(label, problems, detail);
}
catch (Exception ex) { Fail(label, ex.GetType().Name + ": " + ex.Message); }
finally
{
foreach (string path in new[] { source, Path.Combine(work, "ed-edited.pkg") })
{
try { if (File.Exists(path)) File.Delete(path); else if (Directory.Exists(path)) Directory.Delete(path, true); }
catch (IOException) { }
}
}
}
// ---------------------------------------------------------------------------------------------
private static async Task MaintenanceAsync(string pkg, string work)
{
string exfat = Path.Combine(work, "mt.exfat"), ffpkg = Path.Combine(work, "mt.ffpkg");
Cli($"convert \"{pkg}\" --to exfat -o \"{exfat}\"");
Cli($"convert \"{pkg}\" --to ffpkg -o \"{ffpkg}\"");
await Maintenance("repair exFAT", exfat, () => ExfatImageMaintenance.RepairAsync(exfat));
await Maintenance("refresh exFAT AMPR index", exfat, () => ExfatAmprPatcher.RefreshAsync(exfat));
await Maintenance("rebuild FFPKG metadata", ffpkg, () => Ufs2Operations.RebuildWithEditsAsync(ffpkg, static _ => { }));
File.Delete(exfat);
File.Delete(ffpkg);
}
private static async Task Maintenance(string label, string image, Func action)
{
try
{
await action();
var problems = new List();
if (Cli($"verify \"{image}\"") != 0) problems.Add("verify failed afterwards");
string outDir = Path.Combine(Path.GetDirectoryName(image)!, "mt-out");
if (Cli($"extract \"{image}\" -o \"{outDir}\"") != 0) problems.Add("extract failed afterwards");
else
{
(int ok, int bad, int missing) = CompareTree(outDir, skip: []);
if (bad > 0 || missing > 0) problems.Add($"{bad} files changed, {missing} missing");
Report(label, problems, $"{ok} files identical");
Directory.Delete(outDir, true);
return;
}
Report(label, problems, string.Empty);
}
catch (Exception ex) { Fail(label, ex.GetType().Name + ": " + ex.Message); }
}
// ---------------------------------------------------------------------------------------------
private static (int Ok, int Bad, int Missing) CompareTree(string dir, string[] skip)
{
int ok = 0, bad = 0, missing = 0;
foreach ((string key, (long size, string hash)) in _ref)
{
if (skip.Contains(key)) continue;
string path = Path.Combine(dir, key.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(path)) { missing++; continue; }
using FileStream s = File.OpenRead(path);
if (s.Length == size && Convert.ToHexString(SHA256.HashData(s)) == hash) ok++; else bad++;
}
return (ok, bad, missing);
}
private static string? Read(string dir, string relative)
{
string path = Path.Combine(dir, relative.Replace('/', Path.DirectorySeparatorChar));
return File.Exists(path) ? File.ReadAllText(path) : null;
}
private static int Cli(string arguments)
{
var psi = new ProcessStartInfo("cmd.exe", $"/c \"\"{_cli}\" {arguments}\"")
{
UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true
};
using Process p = Process.Start(psi)!;
p.StandardOutput.ReadToEnd();
p.StandardError.ReadToEnd();
p.WaitForExit();
return p.ExitCode;
}
private static void Report(string label, List problems, string detail)
{
if (problems.Count == 0) Console.WriteLine($" PASS {label}: {detail}");
else Fail(label, string.Join("; ", problems));
}
private static void Fail(string label, string why)
{
_failures++;
Console.WriteLine($" FAIL {label}: {why}");
}
}