refactor(windows): put sign-in startup, the branded shortcut and the browser hand-off behind tested seams (#41)

This commit was merged in pull request #41.
This commit is contained in:
james.maes committed 2026-11-11 01:50:43 +00:00
1 parent b5dfb2c1a6
commit 077148ff41
53 files changed
+6256 -691

No files matched your search

+57
View File
@@ -2,6 +2,63 @@
## Unreleased
Branded Start menu shortcut (2.0). The installer step that creates, renames and removes the shortcut under
**Start → Print Connectors** now records, by name, every shortcut it has put there and may still own, and a file
that is not ours no longer fails an install or an uninstall. Nothing changes for an installation whose shortcut
was never touched by anything else.
What counts as ours, here and below: a shortcut that starts the installed companion with no arguments and carries
this installer's description. Someone else's shortcut, a file that is not a shortcut at all (empty, or other
content) and a link are not ours. A file that cannot be opened, because another program holds it, is neither: it is
not judged, since it may well be ours.
- **Install, upgrade or repair with a changed display name.** The new shortcut is written first and the previous
brand's shortcut is removed afterwards, as before. What is different:
- If the previous shortcut cannot be deleted, or cannot be opened for the look taken just before the delete,
the install now succeeds. It used to fail, with both shortcuts on the Start menu and nothing left to remove
the old one later. The old shortcut stays for the moment, a warning in the activity log names it and the
reason, and the next install, upgrade, repair or uninstall removes it.
- The previous shortcut is looked at again immediately before it is deleted, and is left alone if it was
replaced in the meantime.
- If the file under the previous name is not ours, or the recorded name is not a shortcut file name in that
folder, it is left unchanged with a warning and the new shortcut is created. This used to fail the install
after the new shortcut had been written, and every install after it.
- **Any install, upgrade or repair.**
- A file that is not ours under the name the shortcut needs is never overwritten. The name is looked at before
the shortcut is written and again immediately before it is put in place; 1.2.1 looked once, and overwrote a
file that appeared or was replaced in between.
- A shortcut of ours that is marked read-only is replaced, or removed, like any other. It used to fail every
install, upgrade, repair and uninstall until the mark was cleared by hand. A read-only file that is not ours
keeps its mark.
- A temporary file a killed or failed earlier run left in the folder (`.qqq-` and 32 hexadecimal digits,
`.lnk`) is removed. Only a file with exactly such a name is removed for this reason, and such a name is never
recorded. One that cannot be removed is noted as a warning and tried again by the next run.
- An install fails before it has changed anything when a file that is not ours has the name the shortcut
needs, when the record in the registry cannot be opened for writing, or when a recorded shortcut, the
current one included, cannot be opened. The last one now says that the shortcut could not be read; a
same-brand upgrade used to say that a different shortcut already used the name.
- An install can still fail after that. If the new shortcut cannot be written, the record is put back as it
was found, except that the new name stays on record when a shortcut of ours already has that name or when
the temporary file could not be removed. If the shortcut is written and the record then cannot be, the
shortcut stays and its name is already on record, so a later install or the uninstall finds it.
- **Uninstall.** A recorded file that is not ours no longer fails the uninstall: it is left unchanged, a warning
in the activity log says so, and the record is removed. The same holds for a recorded name that is not a
shortcut file name in the folder. Both used to stop the uninstall. Shortcuts an earlier install could not
remove, and temporary files left behind, are removed with the current shortcut. An uninstall still fails,
before it has deleted anything, when a recorded shortcut cannot be opened, when the record cannot be changed,
or when the shortcut folder can no longer be trusted. It fails with the record kept, so that it can be run
again, when a shortcut of ours cannot be opened for the look taken just before its delete or cannot be deleted,
or when a temporary file cannot be deleted; shortcuts it had already deleted by then stay deleted.
- **Registry.** `HKLM\SOFTWARE\QRunIO\PrintAgent` keeps `LastShortcutName` as before and may now also hold
`PendingShortcutNames` (multi-string): the names of a shortcut that is being written and of shortcuts that are
still to be removed. It is normally absent and is removed at uninstall. A value of either name that is of
another kind is not used and is reported as a warning in the activity log each time it is read; an
expandable string under `LastShortcutName` is read as stored, without expansion.
- A warning that cannot be written to the activity log never fails an install or an uninstall.
- **Sign-in startup.** When the task scheduler's program cannot be started at all, the companion's preference
and the uninstall cleanup now say "Could not start Windows Task Scheduler administration." They used to show
the raw Windows error. Nothing else about the sign-in task changes.
Removed the code the service never runs (2.0). The installed product behaves as before; the Core and Windows
libraries lose every path that existed only for the polling transport and the `--legacy-compat` mode.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"Qqq.PrintAgent.Core": 100,
"Qqq.PrintAgent.Presentation": 100,
"Qqq.PrintAgent.Windows": 73,
"Qqq.PrintAgent.Windows": 80,
"qqq-print-agent": 100
}
+3 -1
View File
@@ -64,7 +64,9 @@ To provide customer artwork, add `"logoPath": "acme-logo.png"` and/or `"iconPath
The MSI persists `DisplayName` and `CompanyName` in `HKLM\SOFTWARE\QRunIO\PrintAgent`. Future installations of the ordinary shared MSI, including the in-app updater, recover these values when new `BRANDNAME`/`BRANDCOMPANY` properties are not supplied. ProgramData branding/assets and registry labels are retained through upgrades and full uninstall with device state. Removing `branding.json` therefore does **not** erase persisted customer labels.
The installer creates the companion shortcut under **Start → Print Connectors**. Unsafe filename characters are sanitized; truncated or sanitized names receive a stable hash suffix. Rename cleanup and full-uninstall cleanup only remove the tracked shortcut after verifying its exact installed target, arguments and ownership marker. Other shortcuts are left alone. An existing conflicting shortcut causes an explicit installation error rather than an overwrite.
The installer creates the companion shortcut under **Start → Print Connectors**. Unsafe filename characters are sanitized; truncated or sanitized names receive a stable hash suffix. Rename cleanup and full-uninstall cleanup only remove a tracked shortcut after verifying its exact installed target, arguments and ownership marker, immediately before deleting it. Other shortcuts are left alone. An existing conflicting file under the wanted name causes an explicit installation error rather than an overwrite; from 2.0 the name is checked again immediately before the shortcut is put in place.
From 2.0 the installer tracks, by name, every branded shortcut it has put there, until that shortcut is gone or is found not to be its own any more, and treats what it finds under a tracked name as follows. A file that is not ours (someone else's shortcut, a file that is not a shortcut at all, or a link) is left unchanged, noted as a warning in the activity log and no longer tracked; neither an install nor an uninstall fails because of it. A file that cannot be opened, because another program holds it, is not judged: an install stops before it has changed anything and an uninstall fails with its record kept, and either can be run again once the file is free. A previous brand's shortcut that cannot be removed during a rename stays tracked, with a warning, and is removed by the next install, upgrade, repair or uninstall. A read-only mark on a shortcut of ours is cleared before the shortcut is replaced or removed. The temporary file a shortcut is written to before it is put in place is not tracked; one left in the folder by an interrupted run (`.qqq-` followed by 32 hexadecimal digits and `.lnk`) is removed by the next run or the uninstall.
For a customer rename, rerun the wrapper with revised JSON and the intended MSI. This updates service/product/shortcut labels together with runtime settings. To return to built-in labels, explicitly deploy `displayName: "QQQ Print Agent"` and `companyName: "QRun-IO"` through the wrapper. Omitting `-BrandingFile` preserves existing configuration. Software-management systems may instead supply `BRANDNAME` and `BRANDCOMPANY` as MSI public properties; JSON takes precedence for companion presentation, so keep both sources consistent.
@@ -3,340 +3,48 @@
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text;
using Microsoft.Win32;
using Qqq.PrintAgent.Core;
using System.Diagnostics.CodeAnalysis;
namespace Qqq.PrintAgent.Windows;
/// <summary>Installer-only branding of an owned all-users shortcut; application identity never changes.</summary>
/// <summary>
/// Installer-only branding of an owned all-users shortcut; application identity never changes. This class is the entry
/// point for the installed product: it applies the rules in <see cref="AgentBrandingShortcuts"/> to the real shell, folder
/// security, registry and install paths, and holds no rule of its own.
/// </summary>
public static class AgentBrandingInstaller
{
private const string RegistryPath = @"SOFTWARE\QRunIO\PrintAgent";
private const string OwnershipDescription = "QQQ Print Agent companion";
/// <summary>The places of the installed product, with the machine activity log as the place notes go.</summary>
private static readonly BrandingPlaces Places = BrandingPlaces.Installed(WarnInMachineLog);
/// <summary>The companion executable the shortcut must launch: the fixed machine-wide install path, never a configurable one.</summary>
private static string InstalledExecutable => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "QQQ Print Agent", "App", "Qqq.PrintAgent.exe");
/// <summary>The rules bound to the real shell, folder security, the product's registry key and the installed places; the record's warnings go where the notes go.</summary>
private static readonly AgentBrandingShortcuts Installed = new(new WindowsShortcutShell(), new WindowsPathSecurity(), RegistryShortcutRecord.Machine(Places.TryNote), Places);
/// <summary>The all-users Start menu folder this installer owns, so a customer-branded shortcut lands in one predictable place.</summary>
private static string ShortcutDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), "Print Connectors");
/// <summary>Writes one warning to the machine activity log; branding uses it for the notes of a run that left something alone.</summary>
[ExcludeFromCodeCoverage(Justification = "Opens the machine activity log under ProgramData and writes one warning entry to it. A test must not write that log: it belongs to the installed product and is protected from ordinary users. It passes the text on unchanged and decides nothing but that a note is a warning. When a note is written and what it says is decided in AgentBrandingShortcuts and RegistryShortcutRecord, its installer mark is added in BrandingPlaces.Installed, and a log that cannot be written is handled in BrandingPlaces.TryNote; all are tested with a list in place of the log.")]
private static void WarnInMachineLog(string text) => AgentActivityLog.Create(AgentStore.DefaultDirectory).Warning(text);
/// <summary>
/// Creates or renames the all-users Start menu shortcut to the customer's brand name. It requires an administrator,
/// verifies the install and shortcut folders (no reparse points, trusted owner, no untrusted write access), and refuses to
/// replace a shortcut it does not own. The previous brand's shortcut is removed only after the new one is written, and the
/// chosen file name is recorded so a later run or uninstall can find exactly what this installer made.
/// Creates or renames the all-users Start menu shortcut to the customer's brand name; see
/// <see cref="AgentBrandingShortcuts.ApplyInstalledBranding"/> for what it requires and refuses.
/// </summary>
public static void ApplyInstalledBranding()
{
RequireAdministrator();
MachineStateDirectory.EnsureForPath(AgentStore.DefaultDirectory);
ValidateDirectory(Path.GetDirectoryName(InstalledExecutable)!);
if(!File.Exists(InstalledExecutable))
{
throw new FileNotFoundException("The installed companion is missing.");
}
MsiUpdateInstaller.ValidateNoReparseComponents(InstalledExecutable);
ValidateShortcutDirectory(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms));
CreateShortcutDirectory();
ValidateShortcutDirectory(ShortcutDirectory);
var branding = RuntimeBranding.LoadMachine();
string filename = ShortcutFileName(branding.DisplayName ?? AppIdentity.DefaultInfo.DisplayName);
string destination = ShortcutPath(ShortcutDirectory, filename);
using RegistryKey key = Registry.LocalMachine.CreateSubKey(RegistryPath);
string? previous = key.GetValue("LastShortcutName") as string;
if(File.Exists(destination) && !IsOwnedShortcut(destination))
{
throw new InvalidOperationException("A different shortcut already uses the requested brand name.");
}
WriteShortcut(destination, branding.IconPath);
if(previous is not null && !string.Equals(previous, filename, StringComparison.OrdinalIgnoreCase))
{
RemoveOwnedShortcut(ShortcutPath(ShortcutDirectory, previous));
}
key.SetValue("LastShortcutName", filename, RegistryValueKind.String);
}
[ExcludeFromCodeCoverage(Justification = "Runs the branding rules against this computer's all-users Start menu, registry and install folder, which a test must not change and which need administrator rights. It only passes the call on; the rules it runs are tested in AgentBrandingShortcuts with fakes, and what it binds them to is tested where it is chosen: the places in BrandingPlaces.Installed, the record in RegistryShortcutRecord.Machine, and the shell and folder security adapters against Windows in a temporary folder.")]
public static void ApplyInstalledBranding() => Installed.ApplyInstalledBranding();
/// <summary>
/// Removes the shortcut recorded by the last branding run, and only if it is still the one this installer created and
/// its folder is still safe. If nothing was recorded it does nothing, so uninstalling never deletes a user's own shortcut.
/// </summary>
public static void RemoveInstalledShortcut()
{
RequireAdministrator();
using RegistryKey? key = Registry.LocalMachine.OpenSubKey(RegistryPath, writable: true);
string? filename = key?.GetValue("LastShortcutName") as string;
if(filename is null)
{
return;
}
if(Directory.Exists(ShortcutDirectory))
{
ValidateShortcutDirectory(ShortcutDirectory);
RemoveOwnedShortcut(ShortcutPath(ShortcutDirectory, filename));
}
key!.DeleteValue("LastShortcutName", throwOnMissingValue: false);
}
/// <summary>Removes the shortcuts recorded by earlier branding runs, and only those that are still this installer's; see <see cref="AgentBrandingShortcuts.RemoveInstalledShortcut"/>.</summary>
[ExcludeFromCodeCoverage(Justification = "Runs the branding rules against this computer's all-users Start menu, registry and install folder, which a test must not change and which need administrator rights. It only passes the call on; the rules it runs are tested in AgentBrandingShortcuts with fakes, and what it binds them to is tested where it is chosen: the places in BrandingPlaces.Installed, the record in RegistryShortcutRecord.Machine, and the shell and folder security adapters against Windows in a temporary folder.")]
public static void RemoveInstalledShortcut() => Installed.RemoveInstalledShortcut();
/// <summary>
/// Turns a customer's display name into a safe shortcut file name. Invalid characters and reserved device names are
/// replaced, the length is capped at 100, and when anything was altered a short hash of the original is appended so two
/// different names can never collapse to the same file.
/// </summary>
internal static string ShortcutFileName(string label)
{
string trimmed = label.Trim();
if(trimmed.Length == 0)
{
trimmed = "Print Connector";
}
char[] invalid = Path.GetInvalidFileNameChars();
string clean = new(trimmed.Select(character => invalid.Contains(character) || char.IsControl(character) ? '-' : character).ToArray());
clean = clean.Trim(' ', '.');
string stem = clean.Split('.')[0];
if(clean.Length == 0 || new[] { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" }.Contains(stem, StringComparer.OrdinalIgnoreCase))
{
clean = "Print Connector " + clean;
}
bool changed = !string.Equals(clean, trimmed, StringComparison.Ordinal) || clean.Length > 100;
if(clean.Length > 100)
{
clean = clean[..100].TrimEnd(' ', '.');
}
string suffix = changed ? "-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(trimmed)))[..12] : "";
return clean + suffix + ".lnk";
}
/// <summary>
/// Resolves a shortcut file name inside the owned folder. The name must be a plain, bounded .lnk file name with no
/// directory part, and the resulting full path must stay inside the folder, so a stored or computed name cannot reach elsewhere.
/// </summary>
internal static string ShortcutPath(string directory, string filename)
{
if(filename.Length is < 5 or > 140 || !string.Equals(Path.GetFileName(filename), filename, StringComparison.Ordinal) || filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || !filename.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException("Invalid owned shortcut filename.");
}
string path = Path.GetFullPath(Path.Combine(directory, filename));
MsiUpdateInstaller.ValidateContainedPath(path, directory, allowRoot: false);
return path;
}
/// <summary>
/// Decides whether a shortcut belongs to this application: it must launch the installed companion with no arguments and
/// carry this installer's ownership description. Anything else is someone else's shortcut and is never overwritten or deleted.
/// </summary>
internal static bool MatchesOwnedShortcut(string target, string arguments, string description)
=> string.Equals(target, InstalledExecutable, StringComparison.OrdinalIgnoreCase) && arguments.Length == 0 && string.Equals(description, OwnershipDescription, StringComparison.Ordinal);
/// <summary>
/// Writes the shortcut to a temporary file in the same folder and moves it over the destination, so a crash never leaves a
/// half-written shortcut under the real name. It points only at the installed companion and carries the ownership description.
/// </summary>
private static void WriteShortcut(string path, string? icon)
{
string temporary = Path.Combine(ShortcutDirectory, ".qqq-" + Guid.NewGuid().ToString("N") + ".lnk");
try
{
WithShortcut(temporary, shortcut =>
{
SetProperty(shortcut, "TargetPath", InstalledExecutable);
SetProperty(shortcut, "Arguments", "");
SetProperty(shortcut, "WorkingDirectory", Path.GetDirectoryName(InstalledExecutable)!);
SetProperty(shortcut, "Description", OwnershipDescription);
SetProperty(shortcut, "IconLocation", (icon ?? InstalledExecutable) + ",0");
Invoke(shortcut, "Save", BindingFlags.InvokeMethod, null);
});
MsiUpdateInstaller.ValidateNoReparseComponents(path);
File.Move(temporary, path, overwrite: true);
}
finally
{
if(File.Exists(temporary))
{
File.Delete(temporary);
}
}
}
/// <summary>Reads an existing shortcut through Windows shortcut automation and reports whether it matches this application's ownership marks.</summary>
private static bool IsOwnedShortcut(string path)
{
MsiUpdateInstaller.ValidateNoReparseComponents(path);
bool owned = false;
WithShortcut(path, shortcut => owned = MatchesOwnedShortcut(GetProperty(shortcut, "TargetPath"), GetProperty(shortcut, "Arguments"), GetProperty(shortcut, "Description")));
return owned;
}
/// <summary>
/// Deletes a shortcut only when it is still ours. A missing file is fine; a file that no longer matches the ownership marks
/// is left untouched and reported, because deleting a user's shortcut would be worse than leaving a stale one.
/// </summary>
private static void RemoveOwnedShortcut(string path)
{
if(!File.Exists(path))
{
return;
}
if(!IsOwnedShortcut(path))
{
throw new InvalidOperationException("The tracked shortcut no longer belongs to this application; it was left unchanged.");
}
File.Delete(path);
}
/// <summary>
/// Runs an action against a shortcut object created through the WScript.Shell automation object. The COM objects are
/// always released afterwards, even when the action fails, so installer runs do not leak COM references.
/// </summary>
private static void WithShortcut(string path, Action<object> action)
{
object? shell = null;
object? shortcut = null;
try
{
Type type = Type.GetTypeFromProgID("WScript.Shell") ?? throw new InvalidOperationException("Windows shortcut automation is unavailable.");
shell = Activator.CreateInstance(type) ?? throw new InvalidOperationException("Windows shortcut automation could not start.");
shortcut = Invoke(shell, "CreateShortcut", BindingFlags.InvokeMethod, [path]) ?? throw new InvalidOperationException("Windows could not open the shortcut.");
action(shortcut);
}
finally
{
if(shortcut is not null && Marshal.IsComObject(shortcut))
{
Marshal.FinalReleaseComObject(shortcut);
}
if(shell is not null && Marshal.IsComObject(shell))
{
Marshal.FinalReleaseComObject(shell);
}
}
}
/// <summary>Calls a member of a late-bound COM object with the invariant culture, since shortcut automation has no compile-time type.</summary>
private static object? Invoke(object instance, string name, BindingFlags flags, object[]? arguments)
=> instance.GetType().InvokeMember(name, flags, null, instance, arguments, CultureInfo.InvariantCulture);
/// <summary>Reads a string property of a shortcut, treating a missing value as empty.</summary>
private static string GetProperty(object instance, string name) => Invoke(instance, name, BindingFlags.GetProperty, null) as string ?? "";
/// <summary>Sets a string property of a shortcut.</summary>
private static void SetProperty(object instance, string name, string value) => Invoke(instance, name, BindingFlags.SetProperty, [value]);
/// <summary>Requires the install folder to be free of reparse points and to have a trusted access-control list, so a low-privilege user cannot swap the program the shortcut launches.</summary>
private static void ValidateDirectory(string directory)
{
MsiUpdateInstaller.ValidateNoReparseComponents(directory);
using var identity = WindowsIdentity.GetCurrent();
MsiUpdateInstaller.ValidateTrustedAcl(new DirectoryInfo(directory).GetAccessControl(), identity.User?.Value);
}
/// <summary>
/// Creates the shortcut folder if it is missing with an explicit, protected access list: SYSTEM and Administrators have
/// full control and ordinary users may only read and run. An existing folder is left as it is and validated by the caller.
/// </summary>
private static void CreateShortcutDirectory()
{
MsiUpdateInstaller.ValidateNoReparseComponents(ShortcutDirectory);
if(Directory.Exists(ShortcutDirectory))
{
return;
}
DirectorySecurity security = new();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
security.SetOwner(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null));
foreach(WellKnownSidType principal in new[] { WellKnownSidType.LocalSystemSid, WellKnownSidType.BuiltinAdministratorsSid, WellKnownSidType.BuiltinUsersSid })
{
FileSystemRights rights = principal == WellKnownSidType.BuiltinUsersSid ? FileSystemRights.ReadAndExecute : FileSystemRights.FullControl;
security.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(principal, null), rights, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
}
new DirectoryInfo(ShortcutDirectory).Create(security);
}
/// <summary>Checks that a Start menu folder is not a reparse point and that its security passes the trusted-owner and no-untrusted-write test.</summary>
private static void ValidateShortcutDirectory(string directory)
{
MsiUpdateInstaller.ValidateNoReparseComponents(directory);
ValidateShortcutSecurity(new DirectoryInfo(directory).GetAccessControl());
}
/// <summary>Shared Start menu deletion rights do not permit replacing executable content or shortcut targets.</summary>
internal static void ValidateShortcutSecurity(FileSystemSecurity security)
{
var descriptor = new RawSecurityDescriptor(security.GetSecurityDescriptorBinaryForm(), 0);
if(descriptor.DiscretionaryAcl is null || !TrustedShortcutPrincipal(security.GetOwner(typeof(SecurityIdentifier))?.Value ?? ""))
{
throw new UnauthorizedAccessException("The shortcut directory requires a trusted owner and explicit access-control list.");
}
const FileSystemRights unsafeMutation = FileSystemRights.Write | FileSystemRights.ChangePermissions | FileSystemRights.TakeOwnership | (FileSystemRights)0x10000000 | (FileSystemRights)0x40000000;
foreach(FileSystemAccessRule rule in security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)))
{
bool trustedCreator = rule.PropagationFlags.HasFlag(PropagationFlags.InheritOnly) && string.Equals(rule.IdentityReference.Value, "S-1-3-0", StringComparison.Ordinal);
if(rule.AccessControlType == AccessControlType.Allow && (rule.FileSystemRights & unsafeMutation) != default && !TrustedShortcutPrincipal(rule.IdentityReference.Value) && !trustedCreator)
{
throw new UnauthorizedAccessException("The shortcut directory permits untrusted content or permission changes.");
}
}
}
/// <summary>True for the accounts allowed to own or write the folder: SYSTEM, Administrators and the Windows installer service. Anyone else could redirect a shortcut.</summary>
private static bool TrustedShortcutPrincipal(string sid)
=> sid is "S-1-5-18" or "S-1-5-32-544" or "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464";
/// <summary>Refuses to run unless the caller is an elevated administrator, because branding changes machine-wide shortcuts and registry state.</summary>
private static void RequireAdministrator()
{
using var identity = WindowsIdentity.GetCurrent();
if(!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
{
throw new UnauthorizedAccessException("Machine branding requires an elevated administrator.");
}
}
/// <summary>Whether a shortcut that says this belongs to this application as installed; see <see cref="AgentBrandingShortcuts.MatchesOwnedShortcut"/>.</summary>
internal static bool MatchesOwnedShortcut(string target, string arguments, string description) => Installed.MatchesOwnedShortcut(target, arguments, description);
}
@@ -0,0 +1,274 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.AccessControl;
using System.Security.Cryptography;
using System.Security.Principal;
using System.Text;
using System.Text.RegularExpressions;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The shortcut file, its folder and its name: what the shortcut says and what marks it as ours, how it is written, which
/// folders are trusted, what a new shortcut folder may be accessed by, and how a brand name becomes a file name that
/// stays inside the folder. The naming and access-list rules need nothing from Windows and are static.
/// </summary>
internal sealed partial class AgentBrandingShortcuts
{
/// <summary>The description every shortcut of ours carries; with the program and the empty arguments it is what marks a shortcut as ours.</summary>
private const string OwnershipDescription = "QQQ Print Agent companion";
/// <summary>What a run says when a file that is not ours has the name the new shortcut needs.</summary>
private const string NameTaken = "A different shortcut already uses the requested brand name.";
/// <summary>The all-users Start menu folder this installer owns, so a customer-branded shortcut lands in one predictable place.</summary>
internal string ShortcutDirectory => Path.Combine(places.StartMenuPrograms, "Print Connectors");
/// <summary>
/// Decides whether a shortcut belongs to this application: it must launch the installed companion with no arguments and
/// carry this installer's ownership description. Anything else is someone else's shortcut and is never overwritten or deleted.
/// </summary>
internal bool MatchesOwnedShortcut(string target, string arguments, string description)
=> string.Equals(target, places.InstalledExecutable, StringComparison.OrdinalIgnoreCase) && arguments.Length == 0 && string.Equals(description, OwnershipDescription, StringComparison.Ordinal);
/// <summary>
/// Writes the shortcut to a temporary file in the same folder and moves it to the destination, so a failure never leaves a
/// half-written shortcut under the real name. It points only at the installed companion and carries the ownership
/// description. Whatever the shell left under the temporary name is removed, also when the shell failed part way.
/// <para>
/// The destination is looked at again immediately before the move, because a file could have appeared or been
/// replaced there since the first look. When nothing was there at the first look, the move never overwrites: a file
/// that has appeared since is someone else's and the run is refused. When a shortcut of ours was there, it is
/// replaced only if the second look still finds a shortcut of ours; a read-only mark on it is cleared first, since
/// it would otherwise fail this and every later run. A file that is not ours is never overwritten or changed.
/// </para>
/// </summary>
private void WriteShortcut(string path, string? icon, bool replacingOurs)
{
string temporary = Path.Combine(ShortcutDirectory, ".qqq-" + Guid.NewGuid().ToString("N") + ".lnk");
// The name must match TemporaryName, which is how a later run recognises a temporary file that was left behind.
try
{
shell.Write(temporary, new ShortcutLink(places.InstalledExecutable, "", OwnershipDescription, Path.GetDirectoryName(places.InstalledExecutable)!, (icon ?? places.InstalledExecutable) + ",0"));
MsiUpdateInstaller.ValidateNoReparseComponents(ShortcutDirectory);
if(!File.Exists(path))
{
File.Move(temporary, path, overwrite: false);
return;
}
if(!replacingOurs || !IsOwnedShortcut(path))
{
throw new InvalidOperationException(NameTaken);
}
ClearReadOnly(path);
File.Move(temporary, path, overwrite: true);
}
finally
{
if(File.Exists(temporary))
{
File.Delete(temporary);
}
}
}
/// <summary>
/// The temporary shortcut files an earlier run left in the shortcut folder: a run that was killed between writing
/// its shortcut and moving it into place, or whose temporary file could not be deleted. Only a file whose name is
/// exactly the one a temporary file is given counts; nothing else in the folder is ever touched on this account,
/// and a temporary name is never recorded.
/// </summary>
private List<string> TemporaryFiles()
=> [.. Directory.GetFiles(ShortcutDirectory, ".qqq-*.lnk").Where(path => TemporaryName().IsMatch(Path.GetFileName(path)))];
/// <summary>Matches exactly the name a temporary shortcut file is given: ".qqq-", the 32 lowercase hexadecimal digits of a new identifier, ".lnk". The match has a one second limit.</summary>
[GeneratedRegex("""^\.qqq-[0-9a-f]{32}\.lnk$""", RegexOptions.CultureInvariant, 1000)]
private static partial Regex TemporaryName();
/// <summary>
/// Looks at an existing file under a shortcut name and reports whether it is a shortcut of ours. A link is not ours and
/// is not opened, because this product never writes one. A file that opens and holds no shortcut at all is not ours.
/// A shortcut that says anything but what ours says is not ours. A file that cannot be opened is not judged: nothing
/// is known about it, so the failure is passed on, naming the shortcut, and the caller decides what that means. At a
/// first look it stops the run before anything has changed; at the look before a delete or a replacement the file is
/// left alone and its name stays on record.
/// </summary>
private bool IsOwnedShortcut(string path)
{
MsiUpdateInstaller.ValidateNoReparseComponents(Path.GetDirectoryName(path)!);
if(security.IsLink(path))
{
return false;
}
ShortcutLink? link;
try
{
link = shell.Read(path);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
throw new IOException($"The shortcut \"{Path.GetFileName(path)}\" could not be read, so it cannot be known whether it belongs to this application: {exception.Message}", exception);
}
return link is not null && MatchesOwnedShortcut(link.Target, link.Arguments, link.Description);
}
/// <summary>
/// Clears the read-only mark of a file that has just been found to be a shortcut of ours, so that it can be replaced
/// or deleted. It is never called for a file that is not ours.
/// </summary>
private static void ClearReadOnly(string path)
{
FileAttributes attributes = File.GetAttributes(path);
if(attributes.HasFlag(FileAttributes.ReadOnly))
{
File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly);
}
}
/// <summary>Requires the install folder to be free of reparse points and to have a trusted access-control list, so a low-privilege user cannot swap the program the shortcut launches.</summary>
private void ValidateDirectory(string directory)
{
MsiUpdateInstaller.ValidateNoReparseComponents(directory);
MsiUpdateInstaller.ValidateTrustedAcl(security.ReadDirectorySecurity(directory), security.CurrentUserSid);
}
/// <summary>
/// Creates the shortcut folder if it is missing with an explicit, protected access list: SYSTEM and Administrators have
/// full control and ordinary users may only read and run. An existing folder is left as it is and validated by the caller.
/// </summary>
private void CreateShortcutDirectory()
{
MsiUpdateInstaller.ValidateNoReparseComponents(ShortcutDirectory);
if(Directory.Exists(ShortcutDirectory))
{
return;
}
DirectorySecurity descriptor = new();
descriptor.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
descriptor.SetOwner(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null));
foreach(WellKnownSidType principal in new[] { WellKnownSidType.LocalSystemSid, WellKnownSidType.BuiltinAdministratorsSid, WellKnownSidType.BuiltinUsersSid })
{
FileSystemRights rights = principal == WellKnownSidType.BuiltinUsersSid ? FileSystemRights.ReadAndExecute : FileSystemRights.FullControl;
descriptor.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(principal, null), rights, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
}
security.CreateDirectory(ShortcutDirectory, descriptor);
}
/// <summary>Checks that a Start menu folder is not a reparse point and that its security passes the trusted-owner and no-untrusted-write test.</summary>
private void ValidateShortcutDirectory(string directory)
{
MsiUpdateInstaller.ValidateNoReparseComponents(directory);
ValidateShortcutSecurity(security.ReadDirectorySecurity(directory));
}
/// <summary>Refuses to run unless the caller is an elevated administrator, because branding changes machine-wide shortcuts and registry state.</summary>
private void RequireAdministrator()
{
if(!security.IsAdministrator)
{
throw new UnauthorizedAccessException("Machine branding requires an elevated administrator.");
}
}
/// <summary>
/// Turns a customer's display name into a safe shortcut file name. Invalid characters and reserved device names are
/// replaced, the length is capped at 100, and when anything was altered a short hash of the original is appended so two
/// different names can never collapse to the same file.
/// </summary>
internal static string ShortcutFileName(string label)
{
string trimmed = label.Trim();
if(trimmed.Length == 0)
{
trimmed = "Print Connector";
}
char[] invalid = Path.GetInvalidFileNameChars();
string clean = new(trimmed.Select(character => invalid.Contains(character) || char.IsControl(character) ? '-' : character).ToArray());
clean = clean.Trim(' ', '.');
string stem = clean.Split('.')[0];
if(clean.Length == 0 || new[] { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" }.Contains(stem, StringComparer.OrdinalIgnoreCase))
{
clean = "Print Connector " + clean;
}
bool changed = !string.Equals(clean, trimmed, StringComparison.Ordinal) || clean.Length > 100;
if(clean.Length > 100)
{
clean = clean[..100].TrimEnd(' ', '.');
}
string suffix = changed ? "-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(trimmed)))[..12] : "";
return clean + suffix + ".lnk";
}
/// <summary>
/// Resolves a shortcut file name inside the owned folder. The name must be a plain, bounded .lnk file name with no
/// directory part, and the resulting full path must stay inside the folder, so a stored or computed name cannot reach elsewhere.
/// </summary>
internal static string ShortcutPath(string directory, string filename)
{
if(filename.Length is < 5 or > 140 || !string.Equals(Path.GetFileName(filename), filename, StringComparison.Ordinal) || filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || !filename.EndsWith(".lnk", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException("Invalid owned shortcut filename.");
}
string path = Path.GetFullPath(Path.Combine(directory, filename));
MsiUpdateInstaller.ValidateContainedPath(path, directory, allowRoot: false);
return path;
}
/// <summary>Shared Start menu deletion rights do not permit replacing executable content or shortcut targets.</summary>
internal static void ValidateShortcutSecurity(FileSystemSecurity folderSecurity)
{
var descriptor = new RawSecurityDescriptor(folderSecurity.GetSecurityDescriptorBinaryForm(), 0);
if(descriptor.DiscretionaryAcl is null || !TrustedShortcutPrincipal(folderSecurity.GetOwner(typeof(SecurityIdentifier))?.Value ?? ""))
{
throw new UnauthorizedAccessException("The shortcut directory requires a trusted owner and explicit access-control list.");
}
const FileSystemRights unsafeMutation = FileSystemRights.Write | FileSystemRights.ChangePermissions | FileSystemRights.TakeOwnership | (FileSystemRights)0x10000000 | (FileSystemRights)0x40000000;
foreach(FileSystemAccessRule rule in folderSecurity.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)))
{
bool trustedCreator = rule.PropagationFlags.HasFlag(PropagationFlags.InheritOnly) && string.Equals(rule.IdentityReference.Value, "S-1-3-0", StringComparison.Ordinal);
if(rule.AccessControlType == AccessControlType.Allow && (rule.FileSystemRights & unsafeMutation) != default && !TrustedShortcutPrincipal(rule.IdentityReference.Value) && !trustedCreator)
{
throw new UnauthorizedAccessException("The shortcut directory permits untrusted content or permission changes.");
}
}
}
/// <summary>True for the accounts allowed to own or write the folder: SYSTEM, Administrators and the Windows installer service. Anyone else could redirect a shortcut.</summary>
private static bool TrustedShortcutPrincipal(string sid)
=> sid is "S-1-5-18" or "S-1-5-32-544" or "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464";
}
@@ -0,0 +1,300 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The rules of the branded all-users shortcut: who may change it, which folders must be trustworthy first, what the
/// shortcut says, when one is written, replaced or removed, and what a new shortcut folder may be accessed by. The
/// shortcut file, the folder security and the record of what was made come through <see cref="IShortcutShell"/>,
/// <see cref="IPathSecurity"/> and <see cref="IShortcutRecord"/>, so the rules can be run against fakes in temporary folders.
/// <para>
/// The record rule: every shortcut this product owns on the Start menu under a brand name is named in the record, at
/// every moment. A name is recorded before its file is created and is forgotten only after its file is gone or has
/// been found not to be ours. So a run that fails or is killed at any step, or a shortcut that cannot be removed,
/// leaves nothing behind that a later run or the uninstall cannot find. A recorded name without a file of ours is
/// harmless and is dropped by the next run. The one file of ours that is never named is the temporary file a shortcut
/// is written to before it is moved into place: it is recognised by its name instead, and a run or an uninstall
/// removes any that was left behind.
/// </para>
/// <para>
/// What is ours: a shortcut that starts the installed companion with no arguments and carries the ownership
/// description. Someone else's shortcut, a file that holds no shortcut at all and a link are not ours; they are never
/// overwritten, changed or deleted, and a recorded name that leads to one is forgotten with a note. A file that cannot
/// be opened is neither, because it may be ours: it is never judged. At a first look that stops the run; at the look
/// taken just before a delete or a replacement the file is left alone and its name stays on record.
/// </para>
/// <para>
/// Everything that can be checked is checked before anything is changed, so a run that has to refuse refuses while
/// nothing has changed. A run that fails later, because the shortcut or the record could not be written, leaves the
/// record naming every shortcut of ours. Once the new shortcut is in place, trouble with an earlier one is noted and
/// retried by the next run instead: the installer runs this as a required step, and a leftover shortcut is no reason
/// to fail an install or an upgrade.
/// </para>
/// This part holds the two runs and the record; the shortcut file, the folders and the names are in the other part.
/// </summary>
internal sealed partial class AgentBrandingShortcuts(IShortcutShell shell, IPathSecurity security, IShortcutRecord record, BrandingPlaces places)
{
/// <summary>
/// Creates or renames the all-users Start menu shortcut to the customer's brand name. It requires an administrator,
/// verifies the install and shortcut folders (no reparse points, trusted owner, no untrusted write access), proves
/// the record can be changed, and refuses to replace a shortcut it does not own. Every recorded shortcut other than
/// the wanted one is judged before anything is written: one that cannot be read stops the run, one that is no
/// longer ours is left alone and forgotten. The new shortcut's name then joins the record, next to the earlier
/// shortcuts that are to be removed, before its file is written, so the shortcut is never on the Start menu
/// unrecorded; the file is written and the name recorded as the current shortcut; and only then are the earlier
/// ones removed. When writing fails, the record is put back as it was found, with two exceptions that keep the new
/// name on record: a shortcut of ours was already under the new name and is still there, or the temporary file of
/// the failed write could not be removed, in which case the record must not become empty while that file exists,
/// so that an uninstall still comes here and removes it.
/// Temporary files an earlier run left behind are removed once the run is clear to go, before anything is written.
/// </summary>
internal void ApplyInstalledBranding()
{
RequireAdministrator();
places.EnsureProtectedState(places.StateDirectory);
ValidateDirectory(Path.GetDirectoryName(places.InstalledExecutable)!);
if(!File.Exists(places.InstalledExecutable))
{
throw new FileNotFoundException("The installed companion is missing.");
}
MsiUpdateInstaller.ValidateNoReparseComponents(places.InstalledExecutable);
ValidateShortcutDirectory(places.StartMenuPrograms);
CreateShortcutDirectory();
ValidateShortcutDirectory(ShortcutDirectory);
RuntimeBranding branding = places.LoadBranding(true);
string filename = ShortcutFileName(branding.DisplayName ?? AppIdentity.DefaultInfo.DisplayName);
string destination = ShortcutPath(ShortcutDirectory, filename);
record.RequireChangeable();
string? last = record.ReadLastShortcutName();
IReadOnlyList<string> pending = record.ReadPendingShortcutNames();
bool replacingOurs = File.Exists(destination);
if(replacingOurs && !IsOwnedShortcut(destination))
{
throw new InvalidOperationException(NameTaken);
}
List<string> notes = [];
List<string> leaving = StillOurs(Names(last, pending).Where(name => !SameName(name, filename)), notes);
RemoveLeftovers(notes);
RecordPending(SameName(last, filename) ? leaving : [.. leaving, filename]);
try
{
WriteShortcut(destination, branding.IconPath, replacingOurs);
}
catch
{
if(!replacingOurs && TemporaryFiles().Count == 0)
{
RecordPending(pending);
}
throw;
}
record.WriteLastShortcutName(filename);
RecordPending(RemoveLeaving(leaving, notes));
WriteNotes(notes);
}
/// <summary>
/// Removes every shortcut the record names, and only those that are still ours in a folder that is still safe. If
/// nothing is recorded it does nothing, so uninstalling never deletes a user's own shortcut. Everything that can make
/// it fail is checked before anything is deleted: the record must be changeable, the folder trusted and every
/// recorded shortcut readable. A recorded file that has become someone else's, or a recorded name that is not a
/// shortcut file name in the folder, is left alone with a note and forgotten, because a file that is not ours is no
/// reason to fail an uninstall. A shortcut of ours that cannot be deleted does fail it, with the record kept, so
/// the uninstall can be run again and nothing of ours is left behind unrecorded; the same holds for a temporary
/// shortcut file an earlier run left behind, which is removed here too.
/// </summary>
internal void RemoveInstalledShortcut()
{
RequireAdministrator();
List<string> recorded = Names(record.ReadLastShortcutName(), record.ReadPendingShortcutNames());
if(recorded.Count == 0)
{
return;
}
record.RequireChangeable();
List<string> notes = [];
if(Directory.Exists(ShortcutDirectory))
{
ValidateShortcutDirectory(ShortcutDirectory);
List<string> owned = StillOurs(recorded, notes);
foreach(string leftover in TemporaryFiles())
{
File.Delete(leftover);
}
foreach(string name in owned)
{
RemoveIfStillOurs(name, notes);
}
}
record.ClearPendingShortcutNames();
record.ClearLastShortcutName();
WriteNotes(notes);
}
/// <summary>Every name in the record, the current shortcut first, each once whatever its letter case.</summary>
private static List<string> Names(string? last, IReadOnlyList<string> pending)
{
IEnumerable<string> names = last is null ? pending : [last, .. pending];
return [.. names.Distinct(StringComparer.OrdinalIgnoreCase)];
}
/// <summary>Whether two shortcut file names are the same file; Windows file names do not differ by letter case.</summary>
private static bool SameName(string? first, string? second) => string.Equals(first, second, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Judges recorded names before anything is changed and returns those whose file exists and is still ours. A name
/// that is not a shortcut file name in the shortcut folder, or whose file is not ours (someone else's shortcut, no
/// shortcut at all, or a link), is dropped with a note; a name without a file is dropped silently. A file that
/// cannot be opened is not judged: that throws, so the caller stops while nothing has been changed.
/// </summary>
private List<string> StillOurs(IEnumerable<string> names, List<string> notes)
{
List<string> owned = [];
foreach(string name in names)
{
string path;
try
{
path = ShortcutPath(ShortcutDirectory, name);
}
catch(InvalidDataException)
{
notes.Add("A recorded shortcut name is not a shortcut file name in the shortcut folder. Nothing was removed for it, and it is no longer recorded.");
continue;
}
if(!File.Exists(path))
{
continue;
}
if(IsOwnedShortcut(path))
{
owned.Add(name);
}
else
{
notes.Add(NoLongerOurs(name));
}
}
return owned;
}
/// <summary>The note for a recorded shortcut that was found to be someone else's file.</summary>
private static string NoLongerOurs(string name) => $"The recorded shortcut \"{name}\" no longer belongs to this application. It was left unchanged and is no longer recorded.";
/// <summary>
/// Removes the earlier shortcuts the new one replaces and returns the names of those that are still there and still
/// ours. Nothing the shell or the file system refuses here fails the run: the new shortcut is already in place and
/// recorded, so an earlier one that cannot be opened or deleted now (another program may hold the file), or that
/// the shell cannot read, is noted with the reason and stays recorded, and the next branding run or the uninstall
/// removes it.
/// </summary>
private List<string> RemoveLeaving(List<string> leaving, List<string> notes)
{
List<string> remaining = [];
foreach(string name in leaving)
{
try
{
RemoveIfStillOurs(name, notes);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException or InvalidOperationException)
{
remaining.Add(name);
notes.Add($"The previous shortcut \"{name}\" could not be removed ({exception.GetType().Name}: {exception.Message}). It stays recorded, and the next branding run or the uninstall will try again.");
}
}
return remaining;
}
/// <summary>
/// Deletes one recorded shortcut, looking at it again immediately before the delete, because the file could have been
/// replaced since it was first judged. A file that is gone needs nothing; one that is not ours any more is left
/// alone with a note. A shortcut of ours is deleted even when it is marked read-only: the mark is cleared first,
/// since it would otherwise fail this and every later run. A file that cannot be opened or deleted throws, and the
/// caller decides what that means.
/// </summary>
private void RemoveIfStillOurs(string name, List<string> notes)
{
string path = ShortcutPath(ShortcutDirectory, name);
if(!File.Exists(path))
{
return;
}
if(!IsOwnedShortcut(path))
{
notes.Add(NoLongerOurs(name));
return;
}
ClearReadOnly(path);
File.Delete(path);
}
/// <summary>
/// Removes the temporary shortcut files an earlier run left behind. A branding run does not fail over one it cannot
/// remove: it is noted with the reason and the next run tries again.
/// </summary>
private void RemoveLeftovers(List<string> notes)
{
foreach(string leftover in TemporaryFiles())
{
try
{
File.Delete(leftover);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
notes.Add($"A temporary shortcut file an earlier run left behind (\"{Path.GetFileName(leftover)}\") could not be removed ({exception.GetType().Name}: {exception.Message}). The next branding run or the uninstall will try again.");
}
}
}
/// <summary>Records the names of the other shortcuts this product may own, or forgets that note when there are none.</summary>
private void RecordPending(IReadOnlyList<string> names)
{
if(names.Count == 0)
{
record.ClearPendingShortcutNames();
}
else
{
record.WritePendingShortcutNames(names);
}
}
/// <summary>
/// Writes the notes of a run once its work is done, so no note describes something that then did not happen. A note
/// that cannot be written never fails an install or an uninstall; see <see cref="BrandingPlaces.TryNote"/>.
/// </summary>
private void WriteNotes(List<string> notes)
{
foreach(string note in notes)
{
places.TryNote(note);
}
}
}
+22 -302
View File
@@ -3,20 +3,19 @@
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Security.Principal;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using System.Diagnostics.CodeAnalysis;
namespace Qqq.PrintAgent.Windows;
/// <summary>Explicitly opts the current administrator into an interactive sign-in task for the installed companion.</summary>
public static partial class AgentStartup
/// <summary>
/// Explicitly opts the current administrator into an interactive sign-in task for the installed companion. This class is
/// the entry point for the installed product: it applies the rules in <see cref="AgentStartupTasks"/> to the real task
/// scheduler, the companion's fixed install path and the protected setup folder, and holds no rule of its own.
/// </summary>
public static class AgentStartup
{
private static readonly XNamespace TaskNamespace = "http://schemas.microsoft.com/windows/2004/02/mit/task";
/// <summary>The rules bound to the real task scheduler, the companion's install path and the staging of the installed product.</summary>
private static readonly AgentStartupTasks Installed = new(new WindowsTaskScheduler(), InstalledExecutable, ProtectedStaging.MachineSetup());
@@ -26,315 +25,36 @@ public static partial class AgentStartup
/// <summary>Query only; never registers a task. Call off the UI thread.</summary>
public static AgentStartupStatus GetStatus() => GetStatusAsync().GetAwaiter().GetResult();
/// <summary>
/// Reads the startup task's state for the current administrator. A non-administrator gets an explanation instead of a
/// state. A task with this name that is not exactly ours is reported as foreign and never treated as enabled, so the
/// preference never claims control of someone else's task.
/// </summary>
private static async Task<AgentStartupStatus> GetStatusAsync()
{
using var identity = WindowsIdentity.GetCurrent();
if(!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
{
return new AgentStartupStatus(false, false, "Administrator authorization is required to configure sign-in startup.");
}
bool installed = IsInstalledCompanion(Environment.ProcessPath);
(int code, string output) = await RunSchedulerAsync("/Query", "/TN", TaskName(identity), "/XML").ConfigureAwait(false);
bool enabled = false;
if(code == 0)
{
if(!IsOwnedTaskDefinition(TaskName(identity), output))
{
return new AgentStartupStatus(false, false, "A different task definition uses this startup name. Inspect it in Windows Task Scheduler before changing this preference.");
}
var document = XDocument.Parse(output);
enabled = !string.Equals(document.Root?.Element(TaskNamespace + "Settings")?.Element(TaskNamespace + "Enabled")?.Value, "false", StringComparison.OrdinalIgnoreCase);
}
return new AgentStartupStatus(enabled, installed || enabled, installed
? "Starts the installed companion in your tray at your next sign-in. The service already starts with Windows."
: "Enable this from the MSI-installed companion. Development and portable executables cannot create an elevated sign-in task.");
}
[ExcludeFromCodeCoverage(Justification = "Runs the startup rules against the real task scheduler of this computer, which a test must not query or change. It only passes the call on; the rules it runs are tested in AgentStartupTasks with a fake scheduler, and the staging it binds them to in ProtectedStaging.MachineSetup.")]
public static AgentStartupStatus GetStatus() => Installed.GetStatusAsync().GetAwaiter().GetResult();
/// <summary>
/// Only an explicit user action calls this method; enabling authorizes an elevated task for the current administrator.
/// Disabling deletes only a task that is exactly ours. Enabling is allowed only from the installed companion, writes the
/// task definition to a protected folder, registers it, then reads it back and requires it to match, so a task that
/// Windows stored differently is reported instead of trusted. The temporary definition is always deleted.
/// Disabling deletes only a task that is exactly ours, and enabling is allowed only from the installed companion.
/// </summary>
public static async Task<AgentStartupStatus> SetEnabledAsync(bool enabled)
{
using var identity = WindowsIdentity.GetCurrent();
if(!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
{
throw new UnauthorizedAccessException("Configure sign-in startup from the elevated installed companion.");
}
(int existingCode, string existingXml) = await RunSchedulerAsync("/Query", "/TN", TaskName(identity), "/XML").ConfigureAwait(false);
if(existingCode == 0 && !IsOwnedTaskDefinition(TaskName(identity), existingXml))
{
throw new InvalidOperationException("A different task definition uses this startup name. It was left unchanged; inspect it in Windows Task Scheduler.");
}
if(!enabled)
{
if(existingCode == 0)
{
(int deleteCode, _) = await RunSchedulerAsync("/Delete", "/TN", TaskName(identity), "/F").ConfigureAwait(false);
if(deleteCode != 0)
{
throw new InvalidOperationException("Windows could not remove the sign-in task. Open Task Scheduler to inspect it.");
}
}
}
else
{
if(!IsInstalledCompanion(Environment.ProcessPath))
{
throw new InvalidOperationException("Only the installed Program Files companion can enable elevated sign-in startup.");
}
string setupDirectory = Path.Combine(AgentStore.DefaultDirectory, "setup");
Qqq.PrintAgent.Core.MachineStateDirectory.EnsureForPath(setupDirectory);
Directory.CreateDirectory(setupDirectory);
string definition = Path.Combine(setupDirectory, "startup-" + Guid.NewGuid().ToString("N") + ".xml");
try
{
using FileStream heldDefinition = CreateHeldDefinition(definition, CreateTaskXml(identity.User?.Value ?? throw new InvalidOperationException("Current user has no SID.")));
Qqq.PrintAgent.Core.MachineStateDirectory.ValidateFile(definition);
(int createCode, _) = await RunSchedulerAsync("/Create", "/TN", TaskName(identity), "/XML", definition, "/F").ConfigureAwait(false);
if(createCode != 0)
{
throw new InvalidOperationException("Windows could not register the sign-in task. Check Task Scheduler and administrator permissions.");
}
(int verifyCode, string registered) = await RunSchedulerAsync("/Query", "/TN", TaskName(identity), "/XML").ConfigureAwait(false);
if(verifyCode != 0 || !IsOwnedTaskDefinition(TaskName(identity), registered))
{
throw new InvalidOperationException("The registered startup task could not be verified. Inspect Windows Task Scheduler; an unexpected task was left unchanged.");
}
}
finally
{
File.Delete(definition);
}
}
return await GetStatusAsync().ConfigureAwait(false);
}
/// <summary>The task name for one user: a fixed prefix plus the user's SID, so every administrator has a separate task and the name identifies its owner.</summary>
private static string TaskName(WindowsIdentity identity) => "QQQ Print Agent " + (identity.User?.Value ?? throw new InvalidOperationException("Current user has no SID."));
/// <summary>
/// Writes the task definition and returns it open for reading, with sharing set so Task Scheduler's own reader can open
/// it while this handle stays held. Holding the handle in the validated protected folder prevents the file being replaced
/// between validation and registration.
/// </summary>
internal static FileStream CreateHeldDefinition(string path, string xml)
{
Qqq.PrintAgent.Core.MachineStateDirectory.EnsureForPath(path);
// The validated protected parent prevents replacement while switching from the
// exclusive writer to a read handle compatible with Task Scheduler's own reader.
using(FileStream stream = new(path, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
byte[] bytes = Encoding.Unicode.GetPreamble().Concat(Encoding.Unicode.GetBytes(xml)).ToArray();
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
}
[ExcludeFromCodeCoverage(Justification = "Runs the startup rules against the real task scheduler of this computer, which a test must not query or change. It only passes the call on; the rules it runs are tested in AgentStartupTasks with a fake scheduler, and the staging it binds them to in ProtectedStaging.MachineSetup.")]
public static Task<AgentStartupStatus> SetEnabledAsync(bool enabled) => Installed.SetEnabledAsync(enabled);
/// <summary>Full-uninstall cleanup removes only task definitions that exactly match this application's fixed identity.</summary>
public static async Task RemoveInstalledTasksAsync()
{
using var identity = WindowsIdentity.GetCurrent();
if(!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
{
throw new UnauthorizedAccessException("Removing machine installation startup tasks requires administrator authorization.");
}
(int queryCode, string listing) = await RunSchedulerAsync("/Query", "/FO", "CSV", "/NH").ConfigureAwait(false);
if(queryCode != 0)
{
throw new InvalidOperationException("Could not inspect startup tasks during uninstall.");
}
foreach(string line in listing.Split('\n'))
{
// Task Scheduler quotes CSV fields. Only this fixed prefix and a SID can become a candidate.
if(!line.StartsWith("\"\\QQQ Print Agent ", StringComparison.Ordinal))
{
continue;
}
int closingQuote = line.IndexOf('"', 1);
if(closingQuote < 0)
{
continue;
}
string name = line[1..closingQuote];
if(!OwnedTaskName().IsMatch(name))
{
continue;
}
(int definitionCode, string definition) = await RunSchedulerAsync("/Query", "/TN", name, "/XML").ConfigureAwait(false);
if(definitionCode != 0 || !IsOwnedTaskDefinition(name, definition))
{
continue;
}
(int deleteCode, _) = await RunSchedulerAsync("/Delete", "/TN", name, "/F").ConfigureAwait(false);
if(deleteCode != 0)
{
throw new InvalidOperationException("Could not remove a verified QQQ Print Agent startup task.");
}
}
}
[ExcludeFromCodeCoverage(Justification = "Runs the startup rules against the real task scheduler of this computer, which a test must not query or change. It only passes the call on; the rules it runs are tested in AgentStartupTasks with a fake scheduler, and the staging it binds them to in ProtectedStaging.MachineSetup.")]
public static Task RemoveInstalledTasksAsync() => Installed.RemoveInstalledTasksAsync();
/// <summary>
/// Decides whether a registered task is exactly the one this application creates: a name with a user SID, a single
/// principal that logs on interactively at the highest available level, a single logon trigger and a single action that runs the
/// installed companion with "--tray". Anything else, including unparsable XML, is not ours and is never changed or deleted.
/// </summary>
internal static bool IsOwnedTaskDefinition(string taskName, string xml)
{
Match name = OwnedTaskName().Match(taskName);
if(!name.Success)
{
return false;
}
try
{
var document = XDocument.Parse(xml);
XElement? root = document.Root;
XElement[] principals = root?.Element(TaskNamespace + "Principals")?.Elements().ToArray() ?? [];
XElement[] triggers = root?.Element(TaskNamespace + "Triggers")?.Elements().ToArray() ?? [];
XElement[] actions = root?.Element(TaskNamespace + "Actions")?.Elements().ToArray() ?? [];
string sid = name.Groups["sid"].Value;
return root?.Name == TaskNamespace + "Task" && principals.Length == 1 && triggers.Length == 1 && actions.Length == 1
&& principals[0].Name == TaskNamespace + "Principal"
&& MatchesUserSid(principals[0].Element(TaskNamespace + "UserId")?.Value, sid)
&& string.Equals(principals[0].Element(TaskNamespace + "LogonType")?.Value, "InteractiveToken", StringComparison.Ordinal)
&& string.Equals(principals[0].Element(TaskNamespace + "RunLevel")?.Value, "HighestAvailable", StringComparison.Ordinal)
&& triggers[0].Name == TaskNamespace + "LogonTrigger"
&& MatchesUserSid(triggers[0].Element(TaskNamespace + "UserId")?.Value, sid)
&& actions[0].Name == TaskNamespace + "Exec"
&& string.Equals(actions[0].Element(TaskNamespace + "Command")?.Value, InstalledExecutable, StringComparison.OrdinalIgnoreCase)
&& string.Equals(actions[0].Element(TaskNamespace + "Arguments")?.Value, "--tray", StringComparison.Ordinal);
}
catch(XmlException)
{
return false;
}
}
/// <summary>Whether a registered task is exactly the one this application creates for the installed companion; see <see cref="AgentStartupTasks.IsOwnedTaskDefinition"/>.</summary>
internal static bool IsOwnedTaskDefinition(string taskName, string xml) => Installed.IsOwnedTaskDefinition(taskName, xml);
/// <summary>
/// True when a task's user element is the expected SID, or an account name that resolves to exactly that SID, because Task
/// Scheduler may store a name even when the task was registered with a SID. A different SID, or a name that does not
/// resolve, is rejected, so ownership still requires the exact original identity.
/// </summary>
private static bool MatchesUserSid(string? account, string expectedSid)
{
if(string.Equals(account, expectedSid, StringComparison.Ordinal))
{
return true;
}
if(string.IsNullOrWhiteSpace(account) || account.StartsWith("S-", StringComparison.OrdinalIgnoreCase))
{
return false;
}
try
{
// Task Scheduler may serialize an account name even when registration
// used its SID. Ownership still requires the exact original identity.
var resolved = (SecurityIdentifier)new NTAccount(account).Translate(typeof(SecurityIdentifier));
return string.Equals(resolved.Value, expectedSid, StringComparison.Ordinal);
}
catch(Exception exception) when(exception is IdentityNotMappedException or ArgumentException or System.Security.SecurityException)
{
return false;
}
}
/// <summary>Whether a path is the real installed companion; see <see cref="AgentStartupTasks.IsInstalledCompanion"/>.</summary>
internal static bool IsInstalledCompanion(string? executable) => Installed.IsInstalledCompanion(executable);
/// <summary>Matches only this application's task names (the fixed prefix and a SID), with a one second match limit, so an uninstall scan can never act on an unrelated task.</summary>
[GeneratedRegex("""^\\?QQQ Print Agent (?<sid>S-\d+(?:-\d+)+)$""", RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
private static partial Regex OwnedTaskName();
/// <summary>
/// True only for the real installed companion: it must be the Program Files executable at the exact install path, exist, and
/// neither it nor its two parent folders may be a reparse point, so a development copy or a redirected folder cannot gain the elevated task.
/// </summary>
internal static bool IsInstalledCompanion(string? executable)
{
if(executable is null || !string.Equals(Path.GetFullPath(executable), Path.GetFullPath(InstalledExecutable), StringComparison.OrdinalIgnoreCase) || !File.Exists(executable))
{
return false;
}
return !File.GetAttributes(executable).HasFlag(FileAttributes.ReparsePoint)
&& !File.GetAttributes(Path.GetDirectoryName(executable)!).HasFlag(FileAttributes.ReparsePoint)
&& !File.GetAttributes(Path.GetDirectoryName(Path.GetDirectoryName(executable))!).HasFlag(FileAttributes.ReparsePoint);
}
/// <summary>
/// Builds the Task Scheduler definition for one user: an interactive logon trigger and principal at the highest available
/// level, and a single action running the installed companion with "--tray". It must stay in step with
/// <c>IsOwnedTaskDefinition</c>, which accepts only this shape.
/// </summary>
internal static string CreateTaskXml(string userSid)
{
XNamespace ns = TaskNamespace;
XElement Element(string name, object content) => new(ns + name, content);
XDocument document = new(new XDeclaration("1.0", "utf-16", null),
new XElement(ns + "Task", new XAttribute("version", "1.4"),
Element("RegistrationInfo", Element("Description", "Explicitly enabled by this administrator to show QQQ Print Agent in the tray at sign-in.")),
Element("Triggers", new XElement(ns + "LogonTrigger", Element("Enabled", "true"), Element("UserId", userSid))),
Element("Principals", new XElement(ns + "Principal", new XAttribute("id", "CurrentUser"), Element("UserId", userSid), Element("LogonType", "InteractiveToken"), Element("RunLevel", "HighestAvailable"))),
new XElement(ns + "Settings", Element("MultipleInstancesPolicy", "IgnoreNew"), Element("DisallowStartIfOnBatteries", "false"), Element("StopIfGoingOnBatteries", "false"), Element("Enabled", "true"), Element("ExecutionTimeLimit", "PT0S")),
new XElement(ns + "Actions", new XAttribute("Context", "CurrentUser"), new XElement(ns + "Exec", Element("Command", InstalledExecutable), Element("Arguments", "--tray"), Element("WorkingDirectory", Path.GetDirectoryName(InstalledExecutable)!)))));
return document.Declaration + Environment.NewLine + document;
}
/// <summary>
/// Runs schtasks.exe from the system folder with an argument list (never a shell string) and a 15 second limit, and returns
/// its exit code and output. A hung scheduler is killed with its process tree and reported as a timeout with advice to refresh.
/// </summary>
private static async Task<(int Code, string Output)> RunSchedulerAsync(params string[] arguments)
{
ProcessStartInfo start = new(Path.Combine(Environment.SystemDirectory, "schtasks.exe")) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
foreach(string argument in arguments)
{
start.ArgumentList.Add(argument);
}
using Process process = Process.Start(start) ?? throw new InvalidOperationException("Could not start Windows Task Scheduler administration.");
Task<string> output = process.StandardOutput.ReadToEndAsync();
Task<string> error = process.StandardError.ReadToEndAsync();
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(15));
try
{
await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
}
catch(OperationCanceledException exception)
{
process.Kill(entireProcessTree: true);
throw new TimeoutException("Windows Task Scheduler did not respond within 15 seconds. Refresh the preference to check its current state.", exception);
}
string result = await output.ConfigureAwait(false);
_ = await error.ConfigureAwait(false);
return (process.ExitCode, result);
}
/// <summary>The task definition for one user and the installed companion; see <see cref="AgentStartupTasks.CreateTaskXml"/>.</summary>
internal static string CreateTaskXml(string userSid) => Installed.CreateTaskXml(userSid);
}
@@ -0,0 +1,337 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The rules of the sign-in startup task: what the task says, what counts as this application's task, who may change it
/// and when one is created, replaced or removed. Everything it needs from Windows comes through
/// <see cref="ITaskScheduler"/>, so the rules can be run against a fake; the program the task may launch and the
/// protected place the definition is staged in are given, so a test can point them at a temporary place.
/// </summary>
internal sealed partial class AgentStartupTasks(ITaskScheduler scheduler, string installedExecutable, ProtectedStaging staging)
{
/// <summary>How long one run of the task scheduler may take before it is stopped and reported as not responding.</summary>
internal static readonly TimeSpan SchedulerLimit = TimeSpan.FromSeconds(15);
/// <summary>The XML namespace of a Task Scheduler task definition, which every element of one carries.</summary>
private static readonly XNamespace TaskNamespace = "http://schemas.microsoft.com/windows/2004/02/mit/task";
/// <summary>
/// The only program these rules ever run: the task scheduler's command-line program by its full path in the Windows
/// system folder. It is never looked up by name, so a program of the same name elsewhere on the search path cannot
/// be started in its place with administrator rights.
/// </summary>
internal static string SchedulerProgram => Path.Combine(Environment.SystemDirectory, "schtasks.exe");
/// <summary>
/// Reads the startup task's state for the current administrator. A non-administrator gets an explanation instead of a
/// state. A task with this name that is not exactly ours is reported as foreign and never treated as enabled, so the
/// preference never claims control of someone else's task.
/// </summary>
internal async Task<AgentStartupStatus> GetStatusAsync()
{
StartupCaller caller = scheduler.CurrentCaller();
if(!caller.IsAdministrator)
{
return new AgentStartupStatus(false, false, "Administrator authorization is required to configure sign-in startup.");
}
bool installed = IsInstalledCompanion(caller.ProcessPath);
(int code, string output) = await RunSchedulerAsync("/Query", "/TN", TaskName(caller), "/XML").ConfigureAwait(false);
bool enabled = false;
if(code == 0)
{
if(!IsOwnedTaskDefinition(TaskName(caller), output))
{
return new AgentStartupStatus(false, false, "A different task definition uses this startup name. Inspect it in Windows Task Scheduler before changing this preference.");
}
var document = XDocument.Parse(output);
enabled = !string.Equals(document.Root?.Element(TaskNamespace + "Settings")?.Element(TaskNamespace + "Enabled")?.Value, "false", StringComparison.OrdinalIgnoreCase);
}
return new AgentStartupStatus(enabled, installed || enabled, installed
? "Starts the installed companion in your tray at your next sign-in. The service already starts with Windows."
: "Enable this from the MSI-installed companion. Development and portable executables cannot create an elevated sign-in task.");
}
/// <summary>
/// Only an explicit user action calls this method; enabling authorizes an elevated task for the current administrator.
/// Disabling deletes only a task that is exactly ours. Enabling is allowed only from the installed companion, writes the
/// task definition to a protected folder, registers it, then reads it back and requires it to match, so a task that
/// Windows stored differently is reported instead of trusted. The temporary definition is always deleted.
/// </summary>
internal async Task<AgentStartupStatus> SetEnabledAsync(bool enabled)
{
StartupCaller caller = scheduler.CurrentCaller();
if(!caller.IsAdministrator)
{
throw new UnauthorizedAccessException("Configure sign-in startup from the elevated installed companion.");
}
(int existingCode, string existingXml) = await RunSchedulerAsync("/Query", "/TN", TaskName(caller), "/XML").ConfigureAwait(false);
if(existingCode == 0 && !IsOwnedTaskDefinition(TaskName(caller), existingXml))
{
throw new InvalidOperationException("A different task definition uses this startup name. It was left unchanged; inspect it in Windows Task Scheduler.");
}
if(!enabled)
{
if(existingCode == 0)
{
(int deleteCode, _) = await RunSchedulerAsync("/Delete", "/TN", TaskName(caller), "/F").ConfigureAwait(false);
if(deleteCode != 0)
{
throw new InvalidOperationException("Windows could not remove the sign-in task. Open Task Scheduler to inspect it.");
}
}
}
else
{
if(!IsInstalledCompanion(caller.ProcessPath))
{
throw new InvalidOperationException("Only the installed Program Files companion can enable elevated sign-in startup.");
}
await RegisterAsync(caller).ConfigureAwait(false);
}
return await GetStatusAsync().ConfigureAwait(false);
}
/// <summary>
/// Stages the task definition in the protected setup folder, holds it open while the scheduler registers it, and then
/// reads the registered task back: a task Windows stored differently from ours is reported, never trusted. The staged
/// definition is deleted whatever happens.
/// </summary>
private async Task RegisterAsync(StartupCaller caller)
{
staging.EnsureFolder(staging.Directory);
Directory.CreateDirectory(staging.Directory);
string definition = Path.Combine(staging.Directory, "startup-" + Guid.NewGuid().ToString("N") + ".xml");
try
{
using FileStream heldDefinition = CreateHeldDefinition(definition, CreateTaskXml(caller.UserSid ?? throw new InvalidOperationException("Current user has no SID.")));
staging.ValidateFile(definition);
(int createCode, _) = await RunSchedulerAsync("/Create", "/TN", TaskName(caller), "/XML", definition, "/F").ConfigureAwait(false);
if(createCode != 0)
{
throw new InvalidOperationException("Windows could not register the sign-in task. Check Task Scheduler and administrator permissions.");
}
(int verifyCode, string registered) = await RunSchedulerAsync("/Query", "/TN", TaskName(caller), "/XML").ConfigureAwait(false);
if(verifyCode != 0 || !IsOwnedTaskDefinition(TaskName(caller), registered))
{
throw new InvalidOperationException("The registered startup task could not be verified. Inspect Windows Task Scheduler; an unexpected task was left unchanged.");
}
}
finally
{
File.Delete(definition);
}
}
/// <summary>
/// Writes the task definition and returns it open for reading, with sharing set so Task Scheduler's own reader can open
/// it while this handle stays held. Holding the handle in the validated protected folder prevents the file being replaced
/// between validation and registration. The folder check is the one the staging was given, asked again for the file's
/// own path, so the installed product checks the real machine state and a test checks what it chose to.
/// </summary>
internal FileStream CreateHeldDefinition(string path, string xml)
{
staging.EnsureFolder(path);
// The validated protected parent prevents replacement while switching from the
// exclusive writer to a read handle compatible with Task Scheduler's own reader.
using(FileStream stream = new(path, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
byte[] bytes = Encoding.Unicode.GetPreamble().Concat(Encoding.Unicode.GetBytes(xml)).ToArray();
stream.Write(bytes);
stream.Flush(flushToDisk: true);
}
return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
}
/// <summary>The task name for one user: a fixed prefix plus the user's SID, so every administrator has a separate task and the name identifies its owner.</summary>
private static string TaskName(StartupCaller caller) => "QQQ Print Agent " + (caller.UserSid ?? throw new InvalidOperationException("Current user has no SID."));
/// <summary>Full-uninstall cleanup removes only task definitions that exactly match this application's fixed identity.</summary>
internal async Task RemoveInstalledTasksAsync()
{
if(!scheduler.CurrentCaller().IsAdministrator)
{
throw new UnauthorizedAccessException("Removing machine installation startup tasks requires administrator authorization.");
}
(int queryCode, string listing) = await RunSchedulerAsync("/Query", "/FO", "CSV", "/NH").ConfigureAwait(false);
if(queryCode != 0)
{
throw new InvalidOperationException("Could not inspect startup tasks during uninstall.");
}
foreach(string line in listing.Split('\n'))
{
// Task Scheduler quotes CSV fields. Only this fixed prefix and a SID can become a candidate.
if(!line.StartsWith("\"\\QQQ Print Agent ", StringComparison.Ordinal))
{
continue;
}
int closingQuote = line.IndexOf('"', 1);
if(closingQuote < 0)
{
continue;
}
string name = line[1..closingQuote];
if(!OwnedTaskName().IsMatch(name))
{
continue;
}
(int definitionCode, string definition) = await RunSchedulerAsync("/Query", "/TN", name, "/XML").ConfigureAwait(false);
if(definitionCode != 0 || !IsOwnedTaskDefinition(name, definition))
{
continue;
}
(int deleteCode, _) = await RunSchedulerAsync("/Delete", "/TN", name, "/F").ConfigureAwait(false);
if(deleteCode != 0)
{
throw new InvalidOperationException("Could not remove a verified QQQ Print Agent startup task.");
}
}
}
/// <summary>
/// Decides whether a registered task is exactly the one this application creates: a name with a user SID, a single
/// principal that logs on interactively at the highest available level, a single logon trigger and a single action that runs the
/// installed companion with "--tray". Anything else, including unparsable XML, is not ours and is never changed or deleted.
/// </summary>
internal bool IsOwnedTaskDefinition(string taskName, string xml)
{
Match name = OwnedTaskName().Match(taskName);
if(!name.Success)
{
return false;
}
try
{
var document = XDocument.Parse(xml);
XElement? root = document.Root;
XElement[] principals = root?.Element(TaskNamespace + "Principals")?.Elements().ToArray() ?? [];
XElement[] triggers = root?.Element(TaskNamespace + "Triggers")?.Elements().ToArray() ?? [];
XElement[] actions = root?.Element(TaskNamespace + "Actions")?.Elements().ToArray() ?? [];
string sid = name.Groups["sid"].Value;
return root?.Name == TaskNamespace + "Task" && principals.Length == 1 && triggers.Length == 1 && actions.Length == 1
&& principals[0].Name == TaskNamespace + "Principal"
&& MatchesUserSid(principals[0].Element(TaskNamespace + "UserId")?.Value, sid)
&& string.Equals(principals[0].Element(TaskNamespace + "LogonType")?.Value, "InteractiveToken", StringComparison.Ordinal)
&& string.Equals(principals[0].Element(TaskNamespace + "RunLevel")?.Value, "HighestAvailable", StringComparison.Ordinal)
&& triggers[0].Name == TaskNamespace + "LogonTrigger"
&& MatchesUserSid(triggers[0].Element(TaskNamespace + "UserId")?.Value, sid)
&& actions[0].Name == TaskNamespace + "Exec"
&& string.Equals(actions[0].Element(TaskNamespace + "Command")?.Value, installedExecutable, StringComparison.OrdinalIgnoreCase)
&& string.Equals(actions[0].Element(TaskNamespace + "Arguments")?.Value, "--tray", StringComparison.Ordinal);
}
catch(XmlException)
{
return false;
}
}
/// <summary>
/// True when a task's user element is the expected SID, or an account name that resolves to exactly that SID, because Task
/// Scheduler may store a name even when the task was registered with a SID. A different SID, or a name that does not
/// resolve, is rejected, so ownership still requires the exact original identity.
/// </summary>
private bool MatchesUserSid(string? account, string expectedSid)
{
if(string.Equals(account, expectedSid, StringComparison.Ordinal))
{
return true;
}
if(string.IsNullOrWhiteSpace(account) || account.StartsWith("S-", StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Task Scheduler may serialize an account name even when registration
// used its SID. Ownership still requires the exact original identity.
return string.Equals(scheduler.ResolveAccountSid(account), expectedSid, StringComparison.Ordinal);
}
/// <summary>Matches only this application's task names (the fixed prefix and a SID), with a one second match limit, so an uninstall scan can never act on an unrelated task.</summary>
[GeneratedRegex("""^\\?QQQ Print Agent (?<sid>S-\d+(?:-\d+)+)$""", RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, 1000)]
private static partial Regex OwnedTaskName();
/// <summary>
/// True only for the real installed companion: it must be the Program Files executable at the exact install path, exist, and
/// neither it nor its two parent folders may be a reparse point, so a development copy or a redirected folder cannot gain the elevated task.
/// </summary>
internal bool IsInstalledCompanion(string? executable)
{
if(executable is null || !string.Equals(Path.GetFullPath(executable), Path.GetFullPath(installedExecutable), StringComparison.OrdinalIgnoreCase)
|| scheduler.AttributesOf(executable) is not FileAttributes program || program.HasFlag(FileAttributes.Directory))
{
return false;
}
string folder = Path.GetDirectoryName(executable)!;
return !program.HasFlag(FileAttributes.ReparsePoint)
&& scheduler.AttributesOf(folder) is FileAttributes application && !application.HasFlag(FileAttributes.ReparsePoint)
&& scheduler.AttributesOf(Path.GetDirectoryName(folder)!) is FileAttributes product && !product.HasFlag(FileAttributes.ReparsePoint);
}
/// <summary>
/// Builds the Task Scheduler definition for one user: an interactive logon trigger and principal at the highest available
/// level, and a single action running the installed companion with "--tray". It must stay in step with
/// <c>IsOwnedTaskDefinition</c>, which accepts only this shape.
/// </summary>
internal string CreateTaskXml(string userSid)
{
XNamespace ns = TaskNamespace;
XElement Element(string name, object content) => new(ns + name, content);
XDocument document = new(new XDeclaration("1.0", "utf-16", null),
new XElement(ns + "Task", new XAttribute("version", "1.4"),
Element("RegistrationInfo", Element("Description", "Explicitly enabled by this administrator to show QQQ Print Agent in the tray at sign-in.")),
Element("Triggers", new XElement(ns + "LogonTrigger", Element("Enabled", "true"), Element("UserId", userSid))),
Element("Principals", new XElement(ns + "Principal", new XAttribute("id", "CurrentUser"), Element("UserId", userSid), Element("LogonType", "InteractiveToken"), Element("RunLevel", "HighestAvailable"))),
new XElement(ns + "Settings", Element("MultipleInstancesPolicy", "IgnoreNew"), Element("DisallowStartIfOnBatteries", "false"), Element("StopIfGoingOnBatteries", "false"), Element("Enabled", "true"), Element("ExecutionTimeLimit", "PT0S")),
new XElement(ns + "Actions", new XAttribute("Context", "CurrentUser"), new XElement(ns + "Exec", Element("Command", installedExecutable), Element("Arguments", "--tray"), Element("WorkingDirectory", Path.GetDirectoryName(installedExecutable)!)))));
return document.Declaration + Environment.NewLine + document;
}
/// <summary>
/// Runs the task scheduler's program with an argument list and the 15 second limit, and returns its exit code and output. A scheduler
/// that could not be started, or that hung and was stopped, is reported as such with advice to refresh.
/// </summary>
private async Task<(int Code, string Output)> RunSchedulerAsync(params string[] arguments)
{
TaskSchedulerRun run = await scheduler.RunAsync(SchedulerProgram, arguments, SchedulerLimit).ConfigureAwait(false) ?? throw new InvalidOperationException("Could not start Windows Task Scheduler administration.");
if(run.TimedOut is not null)
{
throw new TimeoutException("Windows Task Scheduler did not respond within 15 seconds. Refresh the preference to check its current state.", run.TimedOut);
}
return (run.ExitCode, run.Output);
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// Where branding works and what it reads: the installed companion, the all-users Start menu programs folder, the machine
/// state folder with the check that it is protected (it throws when the folder cannot be trusted), and the customer's
/// branding, loaded with or without the labels the installer recorded, and where a note goes when branding went ahead
/// although it found something it left alone. The installed product takes its places from <see cref="Installed"/>;
/// a test passes temporary folders, branding of its own and a list.
/// </summary>
internal sealed record BrandingPlaces(string InstalledExecutable, string StartMenuPrograms, string StateDirectory, Action<string> EnsureProtectedState, Func<bool, RuntimeBranding> LoadBranding, Action<string> Note)
{
/// <summary>
/// The places of the installed product, so the entry point cannot bind a weaker check by accident: the companion at
/// its fixed path under Program Files, the all-users Start menu, the machine state folder checked by the machine state
/// guard, and the machine's own branding. Only where notes are written is given, because the machine activity log
/// cannot be opened in a test; each note is marked as coming from the installer here.
/// </summary>
internal static BrandingPlaces Installed(Action<string> log)
=> new(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "QQQ Print Agent", "App", "Qqq.PrintAgent.exe"),
Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), AgentStore.DefaultDirectory, MachineStateDirectory.EnsureForPath, RuntimeBranding.LoadMachine, note => log("[Installer] " + note));
/// <summary>
/// Writes a note without ever failing the caller. A note is for whoever reads the log afterwards: when the log
/// refuses or its disk fails, an install or an uninstall that has done its work is not undone by that.
/// </summary>
internal void TryNote(string note)
{
try
{
Note(note);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
// The log being unavailable is no reason to fail what the note is about.
}
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The rules of the browser hand-off trace: each entry is one timestamped line, the file is started afresh when it has
/// grown past its limit, a failure to write or read never reaches the hand-off, and "recent" means the newest entries
/// inside a time window. The file and the clock are given, so the rules can be run against a file in memory and a clock
/// a test controls.
/// </summary>
internal sealed class BrowserCallbackTrace(ITraceFile file, TimeProvider clock)
{
/// <summary>The size past which the trace is deleted before the next entry is written.</summary>
internal const long Limit = 256 * 1024;
/// <summary>The most entries a reading of the recent trace returns.</summary>
internal const int MostRecent = 40;
/// <summary>How an entry is stamped: local date and time to the millisecond, with no offset, in a form that sorts and reads the same everywhere.</summary>
private const string Stamp = "yyyy-MM-dd HH:mm:ss.fff";
/// <summary>Appends one timestamped line to the trace, deleting the file first when it has grown past the limit; a failure to write never affects the hand-off.</summary>
internal void Write(string message)
{
try
{
if(file.Length > Limit)
{ file.Delete(); }
file.Append(clock.GetLocalNow().ToString(Stamp, CultureInfo.InvariantCulture) + " " + message);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
// The trace is best-effort diagnostics only.
}
}
/// <summary>Returns the trace entries written within the window (newest last, at most 40), or an empty list when there is no trace or it cannot be read.</summary>
internal IReadOnlyList<string> Recent(TimeSpan window)
{
try
{
// Entries are stamped to the millisecond, so the start of the window is taken to the millisecond as well.
long start = clock.GetUtcNow().UtcTicks - window.Ticks;
start -= start % TimeSpan.TicksPerMillisecond;
var recent = new List<string>();
foreach(string line in file.ReadLines())
{
if(StampedAt(line) is long at && at >= start)
{ recent.Add(line); }
}
return recent.Count <= MostRecent ? recent : recent[^MostRecent..];
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
return [];
}
}
/// <summary>
/// The instant an entry was stamped at, in ticks of universal time, or null for a line that does not start with a
/// stamp. A stamp is local time without an offset, so it is read in the time zone of the clock that wrote it and
/// not in whatever zone the machine reading it is set to; in the hour a daylight-saving change repeats, a stamp is
/// read as the later of the two instants it can stand for.
/// </summary>
private long? StampedAt(string line)
{
if(line.Length <= 24 || !DateTime.TryParseExact(line[..23], Stamp, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime local))
{
return null;
}
return local.Ticks - clock.LocalTimeZone.GetUtcOffset(local).Ticks;
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>An <see cref="ITraceFile"/> that is an ordinary file at a given path.</summary>
internal sealed class DiskTraceFile(string path) : ITraceFile
{
/// <summary>The file's size, or zero when there is no file.</summary>
public long Length => File.Exists(path) ? new FileInfo(path).Length : 0;
/// <summary>Deletes the file.</summary>
public void Delete() => File.Delete(path);
/// <summary>Creates the folder if needed and appends the line with a line ending.</summary>
public void Append(string line)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.AppendAllText(path, line + Environment.NewLine);
}
/// <summary>Reads the file line by line as it is enumerated, or returns nothing when there is no file.</summary>
public IEnumerable<string> ReadLines() => File.Exists(path) ? File.ReadLines(path) : [];
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.AccessControl;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The Windows facts about who owns a path and who may change it, and the one action that depends on them: creating a
/// folder that is protected from the moment it exists. An implementation reads and creates; whether an owner or an access
/// list is acceptable, and what access a new folder gets, is decided by the caller.
/// </summary>
internal interface IPathSecurity
{
/// <summary>True when the current process holds administrator rights.</summary>
bool IsAdministrator { get; }
/// <summary>The security identifier of the user the current process runs as, or null for an identity that has none.</summary>
string? CurrentUserSid { get; }
/// <summary>The owner and access list of an existing folder.</summary>
FileSystemSecurity ReadDirectorySecurity(string directory);
/// <summary>The owner and access list of an existing file.</summary>
FileSystemSecurity ReadFileSecurity(string file);
/// <summary>True when what exists at the path is a link or a junction to somewhere else instead of an ordinary file or folder.</summary>
bool IsLink(string path);
/// <summary>Creates a folder that has the given owner and access list from the moment it exists.</summary>
void CreateDirectory(string directory, DirectorySecurity security);
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The machine-wide record of the shortcut files this installer may own on the Start menu, so a later run or an uninstall
/// can find exactly those files. It holds two notes: the name of the current shortcut, and the names of any others, which
/// are a new shortcut while it is being written and earlier ones that have not been removed yet. An implementation only
/// keeps the names; when each is read, written or forgotten, and in which order, is decided by
/// <see cref="AgentBrandingShortcuts"/>.
/// </summary>
internal interface IShortcutRecord
{
/// <summary>
/// Proves that this caller may change the record, creating the place it is kept when that does not exist yet, and
/// throws when the caller may not. It changes no name. The rules call it before they write or delete anything, so a
/// run that could not update the record afterwards fails while nothing has been changed; an uninstall calls it only
/// when a name is recorded, so it never creates the place merely to find it empty.
/// </summary>
void RequireChangeable();
/// <summary>The recorded name of the current shortcut, or null when none is recorded or the record does not exist. Reading never creates or changes anything.</summary>
string? ReadLastShortcutName();
/// <summary>The recorded names of the other shortcuts this installer may own, or none. Reading never creates or changes anything.</summary>
IReadOnlyList<string> ReadPendingShortcutNames();
/// <summary>Records the file name of the current shortcut.</summary>
void WriteLastShortcutName(string filename);
/// <summary>Records the file names of the other shortcuts this installer may own, replacing the names recorded before. The list is never empty; see <see cref="ClearPendingShortcutNames"/>.</summary>
void WritePendingShortcutNames(IReadOnlyList<string> filenames);
/// <summary>Forgets the name of the current shortcut; nothing happens when none is recorded.</summary>
void ClearLastShortcutName();
/// <summary>Forgets the names of the other shortcuts; nothing happens when none are recorded.</summary>
void ClearPendingShortcutNames();
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// Windows shortcut files, read and written through the shell. An implementation only turns a file into what it says and
/// back; whether a shortcut is ours, what a new one must say and where it may be written is decided by
/// <see cref="AgentBrandingShortcuts"/>.
/// </summary>
internal interface IShortcutShell
{
/// <summary>
/// Reads the program, arguments and description of an existing shortcut file. Three outcomes are kept apart, because
/// they mean different things to the rules. A file that cannot be opened for reading (another program holds it, or
/// access is denied) is a failure, passed on as the file system gives it, an <see cref="IOException"/> or an
/// <see cref="UnauthorizedAccessException"/>: nothing is known about that file. A file that opens and holds no
/// shortcut at all (an empty file, or anything else under the name) is null. Anything else is what the shortcut
/// says. A shell that cannot do its work fails with an <see cref="InvalidOperationException"/>.
/// </summary>
ShortcutLink? Read(string path);
/// <summary>Writes a shortcut file that says exactly what the link says.</summary>
void Write(string path, ShortcutLink link);
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The Windows facts and actions the sign-in startup rules are applied to: the task scheduler's command-line program, who
/// is asking, what account a name stands for and what a path is. An implementation only asks Windows and reports what it
/// said; whether a task is ours, whether one may be created, replaced or removed, and what the task says is decided by
/// <see cref="AgentStartupTasks"/>.
/// </summary>
internal interface ITaskScheduler
{
/// <summary>Who the current process runs as, whether with administrator rights, and from which program.</summary>
StartupCaller CurrentCaller();
/// <summary>
/// Runs the task scheduler's command-line program, given by its full path so that which program runs is decided by
/// the rules and not by the search path, with the given arguments, and waits for it for at most the given time.
/// It returns null when the program could not be started, and a run marked as timed out when it had to be stopped.
/// </summary>
Task<TaskSchedulerRun?> RunAsync(string program, IReadOnlyList<string> arguments, TimeSpan limit);
/// <summary>The security identifier an account name stands for on this computer, or null when the name stands for no account.</summary>
string? ResolveAccountSid(string account);
/// <summary>The attributes of a file or folder (a link shows as a reparse point), or null when nothing exists at the path.</summary>
FileAttributes? AttributesOf(string path);
}
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// One small text file of diagnostic lines. An implementation only stores and returns lines; what is written, when the
/// file is started afresh and which lines count as recent is decided by <see cref="BrowserCallbackTrace"/>.
/// </summary>
internal interface ITraceFile
{
/// <summary>The size of the file in bytes; zero when it does not exist.</summary>
long Length { get; }
/// <summary>Removes the file; nothing happens when it does not exist.</summary>
void Delete();
/// <summary>Adds one line at the end, creating the file and its folder when they are missing.</summary>
void Append(string line);
/// <summary>The lines of the file, oldest first; none when it does not exist.</summary>
IEnumerable<string> ReadLines();
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// Where a file is staged for a privileged Windows component to read, and the two checks that make that safe: one that the
/// folder is the protected machine state (it throws when the folder cannot be trusted), and one that a file just written
/// there has a trusted owner and access list (it throws otherwise). The installed product takes <see cref="MachineSetup"/>;
/// a test passes a temporary folder and checks of its own, because a temporary file can never pass the real ones.
/// </summary>
internal sealed record ProtectedStaging(string Directory, Action<string> EnsureFolder, Action<string> ValidateFile)
{
/// <summary>
/// The staging of the installed product, so the entry point cannot bind a weaker check by accident: the setup folder
/// inside the machine state folder, with the machine state guard's own folder and file checks.
/// </summary>
internal static ProtectedStaging MachineSetup() => new(Path.Combine(AgentStore.DefaultDirectory, "setup"), MachineStateDirectory.EnsureForPath, MachineStateDirectory.ValidateFile);
}
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using System.Security.AccessControl;
namespace Qqq.PrintAgent.Windows;
@@ -28,29 +29,35 @@ public sealed partial class QqqBrowserCallback
/// <summary>Returns the companion's path after validating that both this helper and the companion are installed in trusted locations, and throws otherwise.</summary>
/// <summary>The companion's path for this process as installed on this computer; see the overload for what is checked.</summary>
[ExcludeFromCodeCoverage(Justification = "Binds the installation check to the path of the running process, this computer's Program Files folder and the real security descriptors under it. A test does not run from the installed location. It only passes the call on; the check itself is tested through the overload below with temporary folders and a fake of IPathSecurity.")]
private static string InstalledCompanionPath()
=> InstalledCompanionPath(Environment.ProcessPath, Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), new WindowsPathSecurity());
/// <summary>Returns the companion's path after validating that both this helper and the companion are installed in trusted locations, and throws otherwise.</summary>
internal static string InstalledCompanionPath(string? processPath, string programFiles, IPathSecurity security)
{
string executable = Environment.ProcessPath ?? throw new InvalidOperationException("Cannot locate the installed callback helper.");
string executable = processPath ?? throw new InvalidOperationException("Cannot locate the installed callback helper.");
string companion = CompanionSibling(executable);
ValidateCallbackInstallation(executable);
ValidateCallbackInstallation(companion);
ValidateCallbackInstallation(executable, programFiles, security);
ValidateCallbackInstallation(companion, programFiles, security);
return companion;
}
/// <summary>Requires a path to be inside Program Files with no link or junction, and every folder from it up to Program Files to have a trusted access list, so an ordinary user cannot substitute the program that receives the callback.</summary>
private static void ValidateCallbackInstallation(string path)
private static void ValidateCallbackInstallation(string path, string boundary, IPathSecurity security)
{
string boundary = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
MsiUpdateInstaller.ValidateContainedPath(path, boundary, allowRoot: false);
MsiUpdateInstaller.ValidateNoReparseComponents(path);
string? current = Path.GetFullPath(path);
while(current is not null && !string.Equals(current, Path.GetDirectoryName(boundary), StringComparison.OrdinalIgnoreCase))
{
FileSystemSecurity security = Directory.Exists(current) ? new DirectoryInfo(current).GetAccessControl() : new FileInfo(current).GetAccessControl();
MsiUpdateInstaller.ValidateTrustedAcl(security, administratorSid: null);
FileSystemSecurity descriptor = Directory.Exists(current) ? security.ReadDirectorySecurity(current) : security.ReadFileSecurity(current);
MsiUpdateInstaller.ValidateTrustedAcl(descriptor, administratorSid: null);
current = Path.GetDirectoryName(current);
}
}
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace Qqq.PrintAgent.Windows;
@@ -11,33 +12,41 @@ namespace Qqq.PrintAgent.Windows;
/// Diagnostics for the browser hand-off. The protocol handler runs as the ordinary (non-elevated) user, launched by the
/// browser, so it cannot write the protected machine log; it appends one-line, credential-free entries to a small per-user
/// trace file instead, and the companion copies the recent entries into the activity log when a browser sign-in does not
/// complete. The callback address itself (which carries the one-time code and state) is never written.
/// complete. The callback address itself (which carries the one-time code and state) is never written. How entries are
/// written and read back is in <see cref="BrowserCallbackTrace"/>; this part says what the handler records.
/// </summary>
public sealed partial class QqqBrowserCallback
{
private const long TraceLimit = 256 * 1024;
/// <summary>The per-user trace file the protocol handler writes.</summary>
public static string TracePath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "QqqPrintAgent", "auth-callback.log");
/// <summary>Forwards a callback from the protocol handler and records each step and the outcome in the trace file, so a hand-off that never reaches the companion can be diagnosed afterwards.</summary>
public static async Task ForwardFromHandlerAsync(string callback, CancellationToken token = default)
[ExcludeFromCodeCoverage(Justification = "Binds the hand-off to this user's real trace file, this session's real callback pipe and the installed companion under Program Files. A test must not write the user's trace file and does not run from the installed location. It only passes the call on; what is recorded and what happens on failure is tested through the overload below with a trace in memory.")]
public static Task ForwardFromHandlerAsync(string callback, CancellationToken token = default)
=> ForwardFromHandlerAsync(callback, new BrowserCallbackTrace(new DiskTraceFile(TracePath), TimeProvider.System), steps => ForwardAsync(callback, PipeName(), InstalledCompanionPath(), token, steps));
/// <summary>
/// Records that the handler started, forwards the callback, and records the outcome: delivered, or not delivered with
/// the kind and message of the failure, which is then passed on. Only the callback's length is written, never the
/// callback, and the steps of the forwarding itself go to the same trace.
/// </summary>
internal static async Task ForwardFromHandlerAsync(string callback, BrowserCallbackTrace trace, Func<Action<string>, Task> forward)
{
ArgumentNullException.ThrowIfNull(callback);
Trace("Protocol handler started by the browser (process " + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + ", session "
trace.Write("Protocol handler started by the browser (process " + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + ", session "
+ System.Diagnostics.Process.GetCurrentProcess().SessionId.ToString(CultureInfo.InvariantCulture) + "); callback length " + callback.Length.ToString(CultureInfo.InvariantCulture) + ".");
try
{
await ForwardAsync(callback, PipeName(), InstalledCompanionPath(), token, Trace).ConfigureAwait(false);
Trace("Callback delivered: the companion acknowledged it.");
await forward(trace.Write).ConfigureAwait(false);
trace.Write("Callback delivered: the companion acknowledged it.");
}
catch(Exception exception)
{
Trace("Callback NOT delivered: " + exception.GetType().Name + ": " + exception.Message
trace.Write("Callback NOT delivered: " + exception.GetType().Name + ": " + exception.Message
+ (exception.InnerException is null ? "" : " (" + exception.InnerException.GetType().Name + ": " + exception.InnerException.Message + ")"));
throw;
}
@@ -46,43 +55,6 @@ public sealed partial class QqqBrowserCallback
/// <summary>Returns the trace entries written within the window (newest last, at most 40), or an empty list when there is no trace or it cannot be read.</summary>
public static IReadOnlyList<string> RecentTrace(TimeSpan window)
{
try
{
if(!File.Exists(TracePath))
{ return []; }
DateTimeOffset cutoff = DateTimeOffset.Now - window;
var recent = new List<string>();
foreach(string line in File.ReadLines(TracePath))
{
if(line.Length > 24 && DateTimeOffset.TryParseExact(line[..23], "yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out DateTimeOffset at) && at >= cutoff)
{ recent.Add(line); }
}
return recent.Count <= 40 ? recent : recent[^40..];
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
return [];
}
}
/// <summary>Appends one timestamped line to the per-user trace, truncating the file when it grows past the limit; a failure to write never affects the hand-off.</summary>
private static void Trace(string message)
{
try
{
string path = TracePath;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if(File.Exists(path) && new FileInfo(path).Length > TraceLimit)
{ File.Delete(path); }
File.AppendAllText(path, DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + " " + message + Environment.NewLine);
}
catch(Exception exception) when(exception is IOException or UnauthorizedAccessException)
{
// The trace is best-effort diagnostics only.
}
}
[ExcludeFromCodeCoverage(Justification = "Reads this user's real trace file, whose content depends on what the user has done on this computer. It only passes the call on; which entries count as recent is tested in BrowserCallbackTrace with a trace in memory.")]
public static IReadOnlyList<string> RecentTrace(TimeSpan window) => new BrowserCallbackTrace(new DiskTraceFile(TracePath), TimeProvider.System).Recent(window);
}
@@ -0,0 +1,133 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.Win32;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The registry behind <see cref="IShortcutRecord"/>: two values under one key. The current shortcut's name is the
/// string value 1.x already wrote, so an upgrade finds it and a 1.x product restored by a failed upgrade still reads it;
/// the other names are a multi-string value beside it. The base key and the key's path are given: the installed product
/// takes <see cref="Machine"/>, and a test gives it a private hive file in a temporary folder, so every member runs
/// under test and nothing here is excluded from coverage.
/// <para>
/// A value of a kind this record does not write is never used and never passed over silently: it is read as nothing
/// recorded and a warning is given. An expandable string is read as it is stored, with no environment name expanded,
/// because a file name is not a place to run an expansion.
/// </para>
/// </summary>
internal sealed class RegistryShortcutRecord(RegistryKey root, string path, Action<string> warn) : IShortcutRecord
{
/// <summary>The key of the installed product under the machine hive.</summary>
internal const string MachinePath = @"SOFTWARE\QRunIO\PrintAgent";
/// <summary>The string value that names the current shortcut; 1.x wrote the same value.</summary>
internal const string LastName = "LastShortcutName";
/// <summary>The multi-string value that names the other shortcuts.</summary>
internal const string PendingNames = "PendingShortcutNames";
/// <summary>
/// The record of the installed product: this product's key in the machine hive, always in the 64-bit view, so a
/// 32-bit caller could never be redirected to another key. Only where a warning goes is given.
/// </summary>
internal static RegistryShortcutRecord Machine(Action<string> warn) => new(RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64), MachinePath, warn);
/// <summary>The base key the record lives under.</summary>
internal RegistryKey Root => root;
/// <summary>The path of the record's key under the base key.</summary>
internal string KeyPath => path;
/// <summary>Opens the key for writing, creating it when it is missing, and closes it again; Windows refuses when the caller may not write there.</summary>
public void RequireChangeable() => root.CreateSubKey(path).Dispose();
/// <summary>Reads the name of the current shortcut through a read-only handle; a missing key or value is nothing recorded, and so, with a warning, is a value that is not a string.</summary>
public string? ReadLastShortcutName()
{
object? value = Read(LastName);
if(value is null or string)
{
return (string?)value;
}
warn($"The registry value {LastName} is not a string, so it was not used: no current shortcut is taken as recorded.");
return null;
}
/// <summary>Reads the other names through a read-only handle; a missing key or value is nothing recorded, and so, with a warning, is a value that is not a list of strings.</summary>
public IReadOnlyList<string> ReadPendingShortcutNames()
{
object? value = Read(PendingNames);
if(value is null or string[])
{
return (string[]?)value ?? [];
}
warn($"The registry value {PendingNames} is not a list of strings, so it was not used: no other shortcuts are taken as recorded.");
return [];
}
/// <summary>Stores the name as a string value, replacing a value of any kind.</summary>
public void WriteLastShortcutName(string filename)
{
using RegistryKey key = root.CreateSubKey(path);
key.SetValue(LastName, filename, RegistryValueKind.String);
}
/// <summary>Stores the names as a multi-string value, replacing a value of any kind.</summary>
public void WritePendingShortcutNames(IReadOnlyList<string> filenames)
{
using RegistryKey key = root.CreateSubKey(path);
key.SetValue(PendingNames, filenames.ToArray(), RegistryValueKind.MultiString);
}
/// <summary>Deletes the value that names the current shortcut when the key and the value exist.</summary>
public void ClearLastShortcutName() => Delete(LastName);
/// <summary>Deletes the value that names the other shortcuts when the key and the value exist.</summary>
public void ClearPendingShortcutNames() => Delete(PendingNames);
/// <summary>The value as it is stored, or null when the key or the value is missing. Nothing is created and no environment name is expanded.</summary>
private object? Read(string name)
{
using RegistryKey? key = root.OpenSubKey(path);
return key?.GetValue(name, null, RegistryValueOptions.DoNotExpandEnvironmentNames);
}
/// <summary>Deletes one value; a missing key or value needs nothing.</summary>
private void Delete(string name)
{
using RegistryKey? key = root.OpenSubKey(path, writable: true);
key?.DeleteValue(name, throwOnMissingValue: false);
}
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// What a Start menu shortcut says: the program it launches, its arguments, its description, and, when the shortcut is
/// being written, its working folder and icon. A shortcut that was read carries only the first three, which are the ones
/// that decide whose shortcut it is.
/// </summary>
internal sealed record ShortcutLink(string Target, string Arguments, string Description, string WorkingDirectory = "", string IconLocation = "");
@@ -0,0 +1,12 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// Who is asking for a change to sign-in startup: the user's security identifier (null for an identity that has none),
/// whether the process holds administrator rights, and the path of the program that is running.
/// </summary>
internal sealed record StartupCaller(string? UserSid, bool IsAdministrator, string? ProcessPath);
@@ -0,0 +1,12 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// What one run of the task scheduler's command-line program came to: its exit code and what it printed, or, when it did
/// not finish within its limit and was stopped, the cancellation that ended the wait.
/// </summary>
internal sealed record TaskSchedulerRun(int ExitCode, string Output, Exception? TimedOut = null);
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.AccessControl;
using System.Security.Principal;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The real Windows behind <see cref="IPathSecurity"/>: the identity of the current process, a folder's or file's security
/// descriptor as Windows holds it, and folder creation with a descriptor. It judges nothing: whether an owner and an
/// access list can be trusted is decided by the validators that are handed what it reads,
/// <see cref="MsiUpdateInstaller.ValidateTrustedAcl(FileSystemSecurity, string)"/> for the install folder and the callback's installation and
/// <see cref="AgentBrandingShortcuts.ValidateShortcutSecurity"/> for Start menu folders, and what access a new folder
/// gets is decided in <see cref="AgentBrandingShortcuts"/>. Nothing here is excluded from coverage: the identity reads
/// are checked against the process token read another way, and the rest is tested in a temporary folder.
/// </summary>
internal sealed class WindowsPathSecurity : IPathSecurity
{
/// <summary>Whether the current Windows identity is in the administrators role.</summary>
public bool IsAdministrator
{
get
{
using var identity = WindowsIdentity.GetCurrent();
return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
}
}
/// <summary>The current Windows identity's security identifier.</summary>
public string? CurrentUserSid
{
get
{
using var identity = WindowsIdentity.GetCurrent();
return identity.User?.Value;
}
}
/// <summary>Reads the folder's security descriptor.</summary>
public FileSystemSecurity ReadDirectorySecurity(string directory) => new DirectoryInfo(directory).GetAccessControl();
/// <summary>Reads the file's security descriptor.</summary>
public FileSystemSecurity ReadFileSecurity(string file) => new FileInfo(file).GetAccessControl();
/// <summary>Reads the attributes of what exists at the path and reports whether it is a reparse point, which is what a link or a junction is.</summary>
public bool IsLink(string path) => File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint);
/// <summary>Creates the folder with the descriptor applied at creation.</summary>
public void CreateDirectory(string directory, DirectorySecurity security) => new DirectoryInfo(directory).Create(security);
}
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Reflection;
using System.Runtime.InteropServices;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The real Windows behind <see cref="IShortcutShell"/>: the WScript.Shell automation object and the shortcut object it
/// creates. It copies values between a <see cref="ShortcutLink"/> and that object, and releases the COM objects afterwards.
/// The shortcut object does not fail on a file it cannot open or that is no shortcut: it answers with empty values, which
/// would read as somebody else's shortcut. So reading opens the file itself first, lets a failure to open it through,
/// and checks that the file begins as a shortcut file does before the shortcut object is asked anything.
/// It is not excluded from coverage: tests write and read real shortcut files through it in a temporary folder, which
/// checks the property names it uses against Windows itself.
/// </summary>
internal sealed class WindowsShortcutShell : IShortcutShell
{
/// <summary>How every shortcut file begins: the size of its header (76) and the class identifier of a shell link, both fixed by the shell link file format.</summary>
private static readonly byte[] ShortcutFileStart = [0x4C, 0x00, 0x00, 0x00, 0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46];
/// <summary>
/// Opens the file for reading, which fails as the file system fails when another program holds it or access is denied,
/// and keeps it open, shared for reading only, while the shortcut is read, so the file that was checked is the file
/// that is read. A file that does not begin as a shortcut file does (an empty file, or anything else under the name)
/// is null. Otherwise it reads the shortcut's program, arguments and description, treating a missing value as empty.
/// </summary>
public ShortcutLink? Read(string path)
{
using FileStream held = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
byte[] start = new byte[ShortcutFileStart.Length];
if(held.ReadAtLeast(start, start.Length, throwOnEndOfStream: false) < start.Length || !start.AsSpan().SequenceEqual(ShortcutFileStart))
{
return null;
}
ShortcutLink? link = null;
WithShortcut(path, shortcut => link = new ShortcutLink(GetProperty(shortcut, "TargetPath"), GetProperty(shortcut, "Arguments"), GetProperty(shortcut, "Description")));
return link;
}
/// <summary>Creates the shortcut object for the path, sets its five values and saves it.</summary>
public void Write(string path, ShortcutLink link)
=> WithShortcut(path, shortcut =>
{
SetProperty(shortcut, "TargetPath", link.Target);
SetProperty(shortcut, "Arguments", link.Arguments);
SetProperty(shortcut, "WorkingDirectory", link.WorkingDirectory);
SetProperty(shortcut, "Description", link.Description);
SetProperty(shortcut, "IconLocation", link.IconLocation);
Invoke(shortcut, "Save", BindingFlags.InvokeMethod, null);
});
/// <summary>
/// Runs an action against a shortcut object created through the WScript.Shell automation object. The COM objects are
/// always released afterwards, even when the action fails, so installer runs do not leak COM references.
/// </summary>
private static void WithShortcut(string path, Action<object> action)
{
object? shell = null;
object? shortcut = null;
try
{
Type type = Type.GetTypeFromProgID("WScript.Shell") ?? throw new InvalidOperationException("Windows shortcut automation is unavailable.");
shell = Activator.CreateInstance(type) ?? throw new InvalidOperationException("Windows shortcut automation could not start.");
shortcut = Invoke(shell, "CreateShortcut", BindingFlags.InvokeMethod, [path]) ?? throw new InvalidOperationException("Windows could not open the shortcut.");
action(shortcut);
}
finally
{
if(shortcut is not null && Marshal.IsComObject(shortcut))
{
Marshal.FinalReleaseComObject(shortcut);
}
if(shell is not null && Marshal.IsComObject(shell))
{
Marshal.FinalReleaseComObject(shell);
}
}
}
/// <summary>
/// Calls a member of a late-bound COM object with the invariant culture, since shortcut automation has no compile-time
/// type. A call the automation object refuses is reported as one kind of failure that names the member and carries
/// what Windows said, so a caller can tell a shell that could not do its work from a file that could not be opened.
/// </summary>
private static object? Invoke(object instance, string name, BindingFlags flags, object[]? arguments)
{
try
{
return instance.GetType().InvokeMember(name, flags, null, instance, arguments, CultureInfo.InvariantCulture);
}
catch(Exception exception) when(exception is TargetInvocationException or COMException)
{
throw new InvalidOperationException($"Windows shortcut automation failed ({name}): {(exception.InnerException ?? exception).Message}", exception);
}
}
/// <summary>Reads a string property of a shortcut, treating a missing value as empty.</summary>
private static string GetProperty(object instance, string name) => Invoke(instance, name, BindingFlags.GetProperty, null) as string ?? "";
/// <summary>Sets a string property of a shortcut.</summary>
private static void SetProperty(object instance, string name, string value) => Invoke(instance, name, BindingFlags.SetProperty, [value]);
}
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using System.Diagnostics;
using System.Security.Principal;
namespace Qqq.PrintAgent.Windows;
/// <summary>
/// The real Windows behind <see cref="ITaskScheduler"/>: it starts the program it is given, reads the identity of the
/// current process, asks the local security authority what account a name stands for and reads a path's attributes. It
/// reports what Windows said and decides nothing about tasks. Nothing here is excluded from coverage: the program is a
/// parameter, so the tests run a harmless child program in place of the scheduler's, and the other members only read.
/// </summary>
internal sealed class WindowsTaskScheduler : ITaskScheduler
{
/// <summary>Reads the current Windows identity, its administrator role and the path of the running program.</summary>
public StartupCaller CurrentCaller()
{
using var identity = WindowsIdentity.GetCurrent();
return new StartupCaller(identity.User?.Value, new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator), Environment.ProcessPath);
}
/// <summary>
/// Runs the given program with an argument list (never a shell string), without a window, and collects what it
/// prints. Both of its streams are read while it runs, so a program that fills its error stream is not held up. A
/// program that cannot be started is reported as null, and one that does not finish within the limit is killed
/// with its process tree and reported as timed out.
/// </summary>
public async Task<TaskSchedulerRun?> RunAsync(string program, IReadOnlyList<string> arguments, TimeSpan limit)
{
ProcessStartInfo start = new(program) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
foreach(string argument in arguments)
{
start.ArgumentList.Add(argument);
}
using Process? process = Start(start);
if(process is null)
{
return null;
}
Task<string> output = process.StandardOutput.ReadToEndAsync();
Task<string> error = process.StandardError.ReadToEndAsync();
using CancellationTokenSource timeout = new(limit);
try
{
await process.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
}
catch(OperationCanceledException exception)
{
process.Kill(entireProcessTree: true);
return new TaskSchedulerRun(0, "", exception);
}
string result = await output.ConfigureAwait(false);
_ = await error.ConfigureAwait(false);
return new TaskSchedulerRun(process.ExitCode, result);
}
/// <summary>Starts the process; a program that does not exist or that Windows will not run is reported as not started instead of failing.</summary>
private static Process? Start(ProcessStartInfo start)
{
try
{
return Process.Start(start);
}
catch(Win32Exception)
{
return null;
}
}
/// <summary>Translates an account name to its security identifier; a name Windows cannot map is reported as null.</summary>
public string? ResolveAccountSid(string account)
{
try
{
return ((SecurityIdentifier)new NTAccount(account).Translate(typeof(SecurityIdentifier))).Value;
}
catch(Exception exception) when(exception is IdentityNotMappedException or ArgumentException or System.Security.SecurityException)
{
return null;
}
}
/// <summary>Reads the attributes of whatever exists at the path, or null when nothing does.</summary>
public FileAttributes? AttributesOf(string path) => File.Exists(path) || Directory.Exists(path) ? File.GetAttributes(path) : null;
}
@@ -22,7 +22,7 @@ public sealed class AgentBrandingInstallerTests
{
DirectorySecurity security = new();
security.SetSecurityDescriptorSddlForm("O:SYG:SYD:(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)(A;OICI;0x10040;;;S-1-5-21-100-200-300-1001)");
AgentBrandingInstaller.ValidateShortcutSecurity(security);
AgentBrandingShortcuts.ValidateShortcutSecurity(security);
Assert.Throws<UnauthorizedAccessException>(() => MsiUpdateInstaller.ValidateTrustedAcl(security, "S-1-5-18"));
}
@@ -40,7 +40,7 @@ public sealed class AgentBrandingInstallerTests
{
DirectorySecurity security = new();
security.SetSecurityDescriptorSddlForm("O:SYG:SYD:(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;" + rights + ";;;S-1-5-21-100-200-300-1001)");
Assert.Throws<UnauthorizedAccessException>(() => AgentBrandingInstaller.ValidateShortcutSecurity(security));
Assert.Throws<UnauthorizedAccessException>(() => AgentBrandingShortcuts.ValidateShortcutSecurity(security));
}
@@ -51,7 +51,7 @@ public sealed class AgentBrandingInstallerTests
{
DirectorySecurity security = new();
security.SetSecurityDescriptorSddlForm("O:SYG:SYD:(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OIIO;GW;;;S-1-5-21-100-200-300-1001)");
Assert.Throws<UnauthorizedAccessException>(() => AgentBrandingInstaller.ValidateShortcutSecurity(security));
Assert.Throws<UnauthorizedAccessException>(() => AgentBrandingShortcuts.ValidateShortcutSecurity(security));
}
@@ -60,11 +60,11 @@ public sealed class AgentBrandingInstallerTests
[Fact]
public void FriendlyShortcutKeepsItsLabelAndLongNamesHaveStableDistinctSuffixes()
{
Assert.Equal("Acme Print Connector.lnk", AgentBrandingInstaller.ShortcutFileName("Acme Print Connector"));
Assert.Equal("Acme Print Connector.lnk", AgentBrandingShortcuts.ShortcutFileName("Acme Print Connector"));
string longName = new('x', 180);
string first = AgentBrandingInstaller.ShortcutFileName(longName);
Assert.Equal(first, AgentBrandingInstaller.ShortcutFileName(longName));
Assert.NotEqual(first, AgentBrandingInstaller.ShortcutFileName(longName[..179] + "y"), StringComparer.Ordinal);
string first = AgentBrandingShortcuts.ShortcutFileName(longName);
Assert.Equal(first, AgentBrandingShortcuts.ShortcutFileName(longName));
Assert.NotEqual(first, AgentBrandingShortcuts.ShortcutFileName(longName[..179] + "y"), StringComparer.Ordinal);
Assert.True(first.Length <= 120);
}
@@ -79,9 +79,9 @@ public sealed class AgentBrandingInstallerTests
[InlineData("Acme / East: Print*")]
public void UnsafeLabelsBecomeSingleSafeFilenames(string label)
{
string filename = AgentBrandingInstaller.ShortcutFileName(label);
string filename = AgentBrandingShortcuts.ShortcutFileName(label);
string directory = Path.Combine(Path.GetTempPath(), "synthetic-shortcuts");
string path = AgentBrandingInstaller.ShortcutPath(directory, filename);
string path = AgentBrandingShortcuts.ShortcutPath(directory, filename);
Assert.Equal(directory, Path.GetDirectoryName(path));
Assert.Equal(filename, Path.GetFileName(path));
Assert.EndsWith(".lnk", filename, StringComparison.Ordinal);
@@ -97,7 +97,7 @@ public sealed class AgentBrandingInstallerTests
[InlineData("name.lnk:stream")]
public void RegistryFilenameCannotEscapeItsOwnedDirectory(string filename)
{
Assert.Throws<InvalidDataException>(() => AgentBrandingInstaller.ShortcutPath(Path.GetTempPath(), filename));
Assert.Throws<InvalidDataException>(() => AgentBrandingShortcuts.ShortcutPath(Path.GetTempPath(), filename));
}
@@ -0,0 +1,178 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The temporary file a branding run writes its shortcut to before moving it into place. A run that is killed between
/// the two, or that cannot delete the file afterwards, leaves it visible on the Start menu under a name nothing records.
/// These tests pin that a later run and the uninstall remove such a file, that only a file with exactly the temporary
/// name is ever removed on this account, and that the record does not become empty while one exists. They run in
/// temporary folders against fakes.
/// </summary>
public sealed class AgentBrandingLeftoverTests
{
/// <summary>The shortcut of the brand the setup starts with.</summary>
private const string Acme = "Acme Print Connector.lnk";
/// <summary>A name exactly as a temporary shortcut file is given: the prefix, 32 lowercase hexadecimal digits, the extension.</summary>
private const string Leftover = ".qqq-0123456789abcdef0123456789abcdef.lnk";
/// <summary>Names that only resemble a temporary file's: one digit short, upper-case digits, another extension after it, no leading dot, a letter that is no hexadecimal digit, and a shortcut of someone's own.</summary>
private static readonly string[] Lookalikes =
[
".qqq-0123456789abcdef0123456789abcde.lnk",
".qqq-FEDCBA9876543210FEDCBA9876543210.lnk",
".qqq-0123456789abcdef0123456789abcdef.lnk.bak",
"qqq-0123456789abcdef0123456789abcdef.lnk",
".qqq-0123456789abcdef0123456789abcdeg.lnk",
"Somebody's own.lnk",
];
/// <summary>
/// A branding run removes a temporary file an earlier run left behind, and an uninstall does too. Files whose names
/// only resemble a temporary file's are left exactly as they are, the temporary name is never put on record, and
/// nothing is noted.
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void LeftoverTemporaryFileIsRemovedAndNothingElseIs(bool uninstall)
{
using var setup = new BrandingSetup();
_ = setup.PlantShortcut(Acme, setup.OurLink);
setup.Record.Name = Acme;
string leftover = Plant(setup, Leftover);
string[] lookalikes = [.. Lookalikes.Select(name => Plant(setup, name))];
if(uninstall)
{ setup.Rules.RemoveInstalledShortcut(); }
else
{ setup.Rules.ApplyInstalledBranding(); }
Assert.False(File.Exists(leftover));
Assert.All(lookalikes, path => Assert.Equal("half a shortcut", File.ReadAllText(path)));
Assert.Equal(uninstall, !File.Exists(setup.Shortcut(Acme)));
Assert.DoesNotContain(setup.Record.Operations, operation => operation.Contains(".qqq-", StringComparison.Ordinal));
Assert.Empty(setup.Record.Pending);
Assert.Empty(setup.Notes);
}
/// <summary>A run that is refused has changed nothing, and that includes a leftover temporary file: it is removed only once a run is clear to go.</summary>
[Fact]
public void RefusedRunLeavesALeftoverWhereItIs()
{
using var setup = new BrandingSetup();
_ = setup.PlantShortcut(Acme, new ShortcutLink(setup.Installed, "--other", BrandingSetup.Ours));
string leftover = Plant(setup, Leftover);
_ = Assert.Throws<InvalidOperationException>(setup.Rules.ApplyInstalledBranding);
Assert.True(File.Exists(leftover));
}
/// <summary>
/// A leftover that cannot be removed, because another program holds it, does not fail a branding run: the run does
/// its work, a note names the file and the reason, and the next run removes it.
/// </summary>
[Fact]
public void LeftoverThatCannotBeRemovedIsNotedAndRemovedByTheNextRun()
{
using var setup = new BrandingSetup();
Directory.CreateDirectory(setup.Rules.ShortcutDirectory);
string leftover = Plant(setup, Leftover);
using(new FileStream(leftover, FileMode.Open, FileAccess.Read, FileShare.Read))
{
setup.Rules.ApplyInstalledBranding();
}
Assert.True(File.Exists(leftover));
Assert.True(File.Exists(setup.Shortcut(Acme)));
Assert.Equal(Acme, setup.Record.Name);
string note = Assert.Single(setup.Notes);
Assert.StartsWith("A temporary shortcut file an earlier run left behind (\"" + Leftover + "\") could not be removed (", note, StringComparison.Ordinal);
Assert.EndsWith("). The next branding run or the uninstall will try again.", note, StringComparison.Ordinal);
setup.Rules.ApplyInstalledBranding();
Assert.Equal(new[] { setup.Shortcut(Acme) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
_ = Assert.Single(setup.Notes);
}
/// <summary>
/// An uninstall that cannot remove a leftover fails before it has deleted the shortcut or forgotten the record, so
/// that it can be run again; run again once the file is free, it removes both.
/// </summary>
[Fact]
public void UninstallThatCannotRemoveALeftoverFailsWithTheRecordKeptAndCanBeRunAgain()
{
using var setup = new BrandingSetup();
string shortcut = setup.PlantShortcut(Acme, setup.OurLink);
setup.Record.Name = Acme;
string leftover = Plant(setup, Leftover);
using(new FileStream(leftover, FileMode.Open, FileAccess.Read, FileShare.Read))
{
Exception failed = Assert.ThrowsAny<Exception>(setup.Rules.RemoveInstalledShortcut);
Assert.True(failed is IOException or UnauthorizedAccessException);
}
Assert.True(File.Exists(shortcut));
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Changes);
setup.Rules.RemoveInstalledShortcut();
Assert.Empty(Directory.GetFiles(setup.Rules.ShortcutDirectory));
Assert.Null(setup.Record.Name);
}
/// <summary>
/// A first install whose shortcut cannot be written, and whose half-written temporary file cannot be removed either,
/// would leave that file with nothing on record, and an uninstall with nothing on record touches nothing. So the
/// record is not put back to empty while a temporary file exists: the name stays on record, the uninstall therefore
/// comes to the folder, and it removes the file.
/// </summary>
[Fact]
public void RecordIsNotEmptiedWhileATemporaryFileIsLeftBehind()
{
using var setup = new BrandingSetup();
FileStream? held = null;
setup.Shell.WriteFailure = new InvalidOperationException("Windows shortcut automation is unavailable.");
setup.Shell.AfterAbandoning = path => held = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
try
{
Exception failed = Assert.ThrowsAny<Exception>(setup.Rules.ApplyInstalledBranding);
Assert.True(failed is IOException or UnauthorizedAccessException);
}
finally
{
held?.Dispose();
}
string leftover = Assert.Single(setup.Shell.Abandoned);
Assert.Equal(new[] { leftover }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Null(setup.Record.Name);
Assert.Equal(new[] { Acme }, setup.Record.Pending, StringComparer.Ordinal);
setup.Rules.RemoveInstalledShortcut();
Assert.Empty(Directory.GetFiles(setup.Rules.ShortcutDirectory));
Assert.Empty(setup.Record.Pending);
}
/// <summary>Puts a file of the given name in the shortcut folder, half-written as a killed run would leave it, and returns its path.</summary>
private static string Plant(BrandingSetup setup, string name)
{
Directory.CreateDirectory(setup.Rules.ShortcutDirectory);
string path = setup.Shortcut(name);
File.WriteAllText(path, "half a shortcut");
return path;
}
}
@@ -0,0 +1,190 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The branding rules with the real Windows shortcut shell, in temporary folders: real shortcut files that another
/// program holds, that are empty, or that are not shortcuts. With a shell fake these cases can pass while the installed
/// product gets them wrong, because the Windows shortcut object answers with empty values where a fake would fail; so
/// they are run here against Windows itself. Folder security and the record stay fakes, and nothing is written outside
/// the temporary folder.
/// </summary>
public sealed class AgentBrandingRealShellTests
{
/// <summary>The shortcut of the brand the setup starts with.</summary>
private const string Acme = "Acme Print Connector.lnk";
/// <summary>The shortcut of an earlier brand.</summary>
private const string Old = "Old Brand.lnk";
/// <summary>
/// An uninstall while another program holds our shortcut without sharing fails, and keeps the record and the
/// shortcut: the file cannot be opened, so it is not judged, and in particular not taken for someone else's and
/// forgotten. Run again once the file is free, the uninstall removes the shortcut and forgets the record.
/// </summary>
[Fact]
public void UninstallWhileOurShortcutIsHeldFailsAndKeepsTheRecordUntilItCanBeRunAgain()
{
using BrandingSetup setup = RealShellSetup();
setup.Rules.ApplyInstalledBranding();
using(new FileStream(setup.Shortcut(Acme), FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
IOException refused = Assert.Throws<IOException>(setup.Rules.RemoveInstalledShortcut);
Assert.StartsWith("The shortcut \"Acme Print Connector.lnk\" could not be read, so it cannot be known whether it belongs to this application: ", refused.Message, StringComparison.Ordinal);
}
Assert.True(File.Exists(setup.Shortcut(Acme)));
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Notes);
setup.Rules.RemoveInstalledShortcut();
Assert.Empty(Directory.GetFiles(setup.Rules.ShortcutDirectory));
Assert.Null(setup.Record.Name);
Assert.Empty(setup.Notes);
}
/// <summary>
/// A same-brand install, upgrade or repair while another program holds the current shortcut fails saying the shortcut
/// could not be read, not that a different shortcut uses the name; the shortcut and the record are as they were, and
/// the same run succeeds once the file is free.
/// </summary>
[Fact]
public void SameBrandRunWhileTheCurrentShortcutIsHeldSaysItCouldNotBeRead()
{
using BrandingSetup setup = RealShellSetup();
setup.Rules.ApplyInstalledBranding();
using(new FileStream(setup.Shortcut(Acme), FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
IOException refused = Assert.Throws<IOException>(setup.Rules.ApplyInstalledBranding);
Assert.StartsWith("The shortcut \"Acme Print Connector.lnk\" could not be read, so it cannot be known whether it belongs to this application: ", refused.Message, StringComparison.Ordinal);
}
Assert.Equal(new[] { setup.Shortcut(Acme) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
setup.Rules.ApplyInstalledBranding();
Assert.Equal(new[] { setup.Shortcut(Acme) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Empty(setup.Notes);
}
/// <summary>
/// A rename while another program holds the previous brand's shortcut stops before anything is changed: no new
/// shortcut, the old name still on record, nothing noted. Once the file is free the rename goes through, the old
/// shortcut is removed, and an uninstall afterwards leaves nothing.
/// </summary>
[Fact]
public void RenameWhileThePreviousShortcutIsHeldChangesNothingUntilItIsFree()
{
using BrandingSetup setup = RealShellSetup();
setup.Branding = new RuntimeBranding(DisplayName: "Old Brand");
setup.Rules.ApplyInstalledBranding();
setup.Branding = new RuntimeBranding(DisplayName: "Acme Print Connector");
using(new FileStream(setup.Shortcut(Old), FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
IOException refused = Assert.Throws<IOException>(setup.Rules.ApplyInstalledBranding);
Assert.StartsWith("The shortcut \"Old Brand.lnk\" could not be read, so it cannot be known whether it belongs to this application: ", refused.Message, StringComparison.Ordinal);
}
Assert.Equal(new[] { setup.Shortcut(Old) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(Old, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
setup.Rules.ApplyInstalledBranding();
Assert.Equal(new[] { setup.Shortcut(Acme) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
setup.Rules.RemoveInstalledShortcut();
Assert.Empty(Directory.GetFiles(setup.Rules.ShortcutDirectory));
Assert.Null(setup.Record.Name);
Assert.Empty(setup.Notes);
}
/// <summary>
/// A recorded file that opens and is no shortcut at all, empty or of other content, is not ours: a rename leaves it
/// exactly as it is, notes it and forgets it, and so does an uninstall, which still removes the real shortcut
/// recorded beside it.
/// </summary>
[Theory]
[InlineData(true, true)]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(false, false)]
public void RecordedFileThatIsNoShortcutIsLeftAloneAndForgotten(bool empty, bool uninstall)
{
using BrandingSetup setup = RealShellSetup();
setup.Rules.ApplyInstalledBranding();
string other = setup.Shortcut(Old);
byte[] content = empty ? [] : [.. Enumerable.Repeat((byte)'x', 4096)];
File.WriteAllBytes(other, content);
setup.Record.Pending.Add(Old);
if(uninstall)
{
setup.Rules.RemoveInstalledShortcut();
Assert.Null(setup.Record.Name);
Assert.Equal(new[] { other }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
}
else
{
setup.Rules.ApplyInstalledBranding();
Assert.Equal(Acme, setup.Record.Name);
Assert.Equal(new[] { setup.Shortcut(Acme), other }, Directory.GetFiles(setup.Rules.ShortcutDirectory).Order(StringComparer.Ordinal), StringComparer.Ordinal);
}
Assert.Equal(content, File.ReadAllBytes(other));
Assert.Empty(setup.Record.Pending);
Assert.Equal(new[] { "The recorded shortcut \"Old Brand.lnk\" no longer belongs to this application. It was left unchanged and is no longer recorded." }, setup.Notes, StringComparer.Ordinal);
}
/// <summary>
/// A real shortcut of ours that is marked read-only is replaced by a same-brand run, removed by a rename, and removed
/// by an uninstall. Each of these used to fail, at the move or at the delete, until someone cleared the mark by hand.
/// </summary>
[Theory]
[InlineData("same-brand")]
[InlineData("rename")]
[InlineData("uninstall")]
public void ReadOnlyShortcutOfOursDoesNotStopARun(string run)
{
using BrandingSetup setup = RealShellSetup();
setup.Rules.ApplyInstalledBranding();
File.SetAttributes(setup.Shortcut(Acme), FileAttributes.ReadOnly);
if(run is "uninstall")
{
setup.Rules.RemoveInstalledShortcut();
Assert.Empty(Directory.GetFiles(setup.Rules.ShortcutDirectory));
Assert.Null(setup.Record.Name);
}
else
{
string wanted = run is "rename" ? "Globex Connector.lnk" : Acme;
setup.Branding = new RuntimeBranding(DisplayName: Path.GetFileNameWithoutExtension(wanted));
setup.Rules.ApplyInstalledBranding();
Assert.Equal(new[] { setup.Shortcut(wanted) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.False(File.GetAttributes(setup.Shortcut(wanted)).HasFlag(FileAttributes.ReadOnly));
Assert.Equal(wanted, setup.Record.Name);
}
Assert.Empty(setup.Record.Pending);
Assert.Empty(setup.Notes);
}
/// <summary>The rules in temporary folders with the real shortcut shell; the test fails clearly when the machine has no shortcut automation.</summary>
private static BrandingSetup RealShellSetup()
{
Assert.True(Type.GetTypeFromProgID("WScript.Shell") is not null, WindowsShortcutShellTests.Unavailable);
return new BrandingSetup(new WindowsShortcutShell());
}
}
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// Every state the record and the shortcut folder can be left in by a run that was killed, or that failed, part way,
/// and the state a 1.2.x install leaves. Each is followed once by an install of the wanted brand and once by an
/// uninstall, and must come out clean: the install ends with exactly the wanted shortcut and only its name on record,
/// the uninstall with no shortcut of ours and nothing on record. No state may make every later run fail, and none may
/// leave a shortcut of ours behind with nothing that records it. The rules run in temporary folders against fakes.
/// </summary>
public sealed class AgentBrandingRecordStateTests
{
/// <summary>The earlier brand's shortcut, written X in the states below.</summary>
private const string X = "Old Brand.lnk";
/// <summary>The wanted brand's shortcut, written Y in the states below.</summary>
private const string Y = "Acme Print Connector.lnk";
/// <summary>
/// The states, each given as the current name, the other names and the shortcuts of ours on disk, in the letters X
/// (the earlier brand) and Y (the wanted brand):
/// a rename killed after both names went on record and before the new shortcut was written;
/// the same killed after the shortcut was moved into place and before it was made current;
/// the same killed after it was made current and before the earlier shortcut was removed;
/// a first install killed after the shortcut was moved into place, which leaves only an other name on record;
/// the same killed before the shortcut was written;
/// and what a 1.2.x install leaves, one current name and nothing else.
/// </summary>
[Theory]
[InlineData("X", "XY", "X", false)]
[InlineData("X", "XY", "X", true)]
[InlineData("X", "XY", "XY", false)]
[InlineData("X", "XY", "XY", true)]
[InlineData("Y", "XY", "XY", false)]
[InlineData("Y", "XY", "XY", true)]
[InlineData("", "Y", "Y", false)]
[InlineData("", "Y", "Y", true)]
[InlineData("", "Y", "", false)]
[InlineData("", "Y", "", true)]
[InlineData("X", "", "X", false)]
[InlineData("X", "", "X", true)]
public void StateLeftByAnInterruptedRunComesOutCleanAfterAnInstallAndAfterAnUninstall(string last, string pending, string onDisk, bool uninstall)
{
using var setup = new BrandingSetup();
Arrange(setup, last, pending, onDisk);
if(uninstall)
{
setup.Rules.RemoveInstalledShortcut();
Assert.Empty(Directory.GetFiles(setup.Rules.ShortcutDirectory));
Assert.Null(setup.Record.Name);
}
else
{
setup.Rules.ApplyInstalledBranding();
Assert.Equal(new[] { setup.Shortcut(Y) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(Y, setup.Record.Name);
}
Assert.Empty(setup.Record.Pending);
Assert.Empty(setup.Notes);
}
/// <summary>
/// The state a rename leaves when the earlier shortcut could not be deleted: the wanted shortcut is current and the
/// earlier one is on record beside it. While the earlier shortcut still cannot be deleted, an install succeeds and
/// keeps it on record with a note, and an uninstall fails with the record kept; once it is free, either one finishes
/// the job.
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void EarlierShortcutThatStillCannotBeDeletedStaysOnRecordUntilItCan(bool uninstall)
{
using var setup = new BrandingSetup();
Arrange(setup, "Y", "X", "XY");
using(new FileStream(setup.Shortcut(X), FileMode.Open, FileAccess.Read, FileShare.Read))
{
if(uninstall)
{
Exception failed = Assert.ThrowsAny<Exception>(setup.Rules.RemoveInstalledShortcut);
Assert.True(failed is IOException or UnauthorizedAccessException);
Assert.Empty(setup.Record.Changes);
}
else
{
setup.Rules.ApplyInstalledBranding();
Assert.True(File.Exists(setup.Shortcut(Y)));
_ = Assert.Single(setup.Notes);
}
}
Assert.True(File.Exists(setup.Shortcut(X)));
Assert.Equal(Y, setup.Record.Name);
Assert.Equal(new[] { X }, setup.Record.Pending, StringComparer.Ordinal);
if(uninstall)
{
setup.Rules.RemoveInstalledShortcut();
Assert.Empty(Directory.GetFiles(setup.Rules.ShortcutDirectory));
Assert.Null(setup.Record.Name);
}
else
{
setup.Rules.ApplyInstalledBranding();
Assert.Equal(new[] { setup.Shortcut(Y) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(Y, setup.Record.Name);
}
Assert.Empty(setup.Record.Pending);
}
/// <summary>Puts the record and the shortcut folder in a state: the current name, the other names and the shortcuts of ours on disk, each given in the letters X and Y.</summary>
private static void Arrange(BrandingSetup setup, string last, string pending, string onDisk)
{
Directory.CreateDirectory(setup.Rules.ShortcutDirectory);
setup.Record.Name = last.Length == 0 ? null : Name(last[0]);
setup.Record.Pending.AddRange(pending.Select(Name));
foreach(char letter in onDisk)
{ _ = setup.PlantShortcut(Name(letter), setup.OurLink); }
}
/// <summary>The shortcut file name a letter stands for.</summary>
private static string Name(char letter) => letter == 'X' ? X : Y;
}
@@ -0,0 +1,273 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.AccessControl;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// What uninstall does with the branded shortcut, run in temporary folders against fakes: it removes exactly the
/// shortcuts on record that are still ours, leaves and forgets what has become someone else's, and fails, before it has
/// changed anything, only when it cannot know or cannot finish. The installer runs this as a required step, so each
/// failure here fails an uninstall. No test touches this computer's Start menu or registry.
/// </summary>
public sealed class AgentBrandingRemovalTests
{
/// <summary>The shortcut of the current brand.</summary>
private const string Acme = "Acme Print Connector.lnk";
/// <summary>The shortcut of an earlier brand.</summary>
private const string Old = "Old Brand.lnk";
/// <summary>What an uninstall does to the record when it only looks: the two reads, with no proof of changeability and no change.</summary>
private static readonly string[] ReadsOnly = ["read-last", "read-pending"];
/// <summary>Removal with nothing recorded does nothing: the record is read without being created or opened for change, no folder is checked, no file is looked at and nothing is cleared.</summary>
[Fact]
public void RemovalWithNothingRecordedTouchesNothing()
{
using var setup = new BrandingSetup();
string theirs = setup.PlantShortcut(Acme, setup.OurLink);
setup.Rules.RemoveInstalledShortcut();
Assert.Equal(ReadsOnly, setup.Record.Operations, StringComparer.Ordinal);
Assert.Empty(setup.Security.Reads);
Assert.Empty(setup.Shell.Reads);
Assert.True(File.Exists(theirs));
}
/// <summary>
/// Removal deletes every recorded shortcut that is still ours, the current one and any an earlier run left on record,
/// and then forgets the record; a recorded shortcut or folder that is already gone is forgotten without error. The
/// record is proved changeable before any file is looked at.
/// </summary>
[Theory]
[InlineData("present")]
[InlineData("with-earlier")]
[InlineData("file-gone")]
[InlineData("folder-gone")]
public void RemovalDeletesOurRecordedShortcutsAndForgetsThem(string state)
{
using var setup = new BrandingSetup();
setup.Record.Name = Acme;
if(state is not "folder-gone")
{ Directory.CreateDirectory(setup.Rules.ShortcutDirectory); }
if(state is "present" or "with-earlier")
{ _ = setup.PlantShortcut(Acme, setup.OurLink); }
if(state is "with-earlier")
{
_ = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Pending.Add(Old);
}
setup.Rules.RemoveInstalledShortcut();
Assert.Null(setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Equal(new[] { "read-last", "read-pending", "require", "clear-pending", "clear-last" }, setup.Record.Operations, StringComparer.Ordinal);
Assert.Equal(state is "folder-gone" ? 0 : 1, setup.Security.Reads.Count);
Assert.False(Directory.Exists(setup.Rules.ShortcutDirectory) && Directory.GetFiles(setup.Rules.ShortcutDirectory).Length > 0);
Assert.Empty(setup.Notes);
}
/// <summary>
/// A recorded file that is not a shortcut of ours, or a recorded name that is not a shortcut file name in the
/// shortcut folder, does not fail the uninstall: the file is left exactly as it is, one note says so, the record is
/// forgotten, and a shortcut of ours recorded beside it is still removed. Not ours are someone else's shortcut, a
/// file that holds no shortcut at all (empty, or anything else) and a link, which this product never writes.
/// </summary>
[Theory]
[InlineData("foreign", "The recorded shortcut \"Old Brand.lnk\" no longer belongs to this application. It was left unchanged and is no longer recorded.")]
[InlineData("empty", "The recorded shortcut \"Old Brand.lnk\" no longer belongs to this application. It was left unchanged and is no longer recorded.")]
[InlineData("garbage", "The recorded shortcut \"Old Brand.lnk\" no longer belongs to this application. It was left unchanged and is no longer recorded.")]
[InlineData("link", "The recorded shortcut \"Old Brand.lnk\" no longer belongs to this application. It was left unchanged and is no longer recorded.")]
[InlineData("name-escapes", "A recorded shortcut name is not a shortcut file name in the shortcut folder. Nothing was removed for it, and it is no longer recorded.")]
public void RemovalLeavesAndForgetsWhatIsNoLongerOurs(string problem, string note)
{
using var setup = new BrandingSetup();
string ours = setup.PlantShortcut(Acme, setup.OurLink);
string theirs = setup.PlantShortcut(Old, problem is "foreign" ? new ShortcutLink(setup.Installed, "--other", BrandingSetup.Ours) : setup.OurLink);
if(problem is "empty")
{ File.WriteAllBytes(theirs, []); }
else if(problem is "garbage")
{ File.WriteAllText(theirs, "not a shortcut at all"); }
else if(problem is "link")
{ _ = setup.Security.Links.Add(theirs); }
string outside = Path.Combine(setup.Programs, Old);
FakeShortcutShell.Plant(outside, setup.OurLink);
string theirsBefore = File.ReadAllText(theirs);
setup.Record.Name = problem is "name-escapes" ? "..\\" + Old : Old;
setup.Record.Pending.Add(Acme);
setup.Rules.RemoveInstalledShortcut();
Assert.Equal(theirsBefore, File.ReadAllText(theirs));
Assert.True(File.Exists(outside));
Assert.False(File.Exists(ours));
Assert.Null(setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Equal(new[] { note }, setup.Notes, StringComparer.Ordinal);
}
/// <summary>
/// Removal still fails, and fails before it has deleted or forgotten anything, when it cannot know or cannot finish:
/// a recorded shortcut that cannot be read (it may be ours), a record that cannot be changed (the shortcut would be
/// deleted and still recorded), and a shortcut folder that can no longer be trusted (deleting in it is not safe).
/// </summary>
[Theory]
[InlineData("unreadable", "Windows could not open the shortcut.")]
[InlineData("record", "Synthetic: the record refuses change.")]
[InlineData("folder-untrusted", "The shortcut directory permits untrusted content or permission changes.")]
public void RemovalThatCannotBeSureFailsBeforeAnythingIsChanged(string problem, string message)
{
using var setup = new BrandingSetup();
string current = setup.PlantShortcut(Acme, setup.OurLink);
string earlier = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Acme;
setup.Record.Pending.Add(Old);
if(problem is "unreadable")
{
setup.Shell.ReadFailure = new InvalidOperationException(message);
setup.Shell.ReadFailureOn = earlier;
}
else if(problem is "record")
{ setup.Record.ChangeRefusal = new UnauthorizedAccessException(message); }
else
{ setup.Security.Descriptors[setup.Rules.ShortcutDirectory] = FakePathSecurity.Descriptor(alsoAllow: FakePathSecurity.Users, rights: FileSystemRights.Write); }
Exception refused = Assert.ThrowsAny<Exception>(setup.Rules.RemoveInstalledShortcut);
Assert.Equal(message, refused.Message);
Assert.True(File.Exists(current));
Assert.True(File.Exists(earlier));
Assert.Empty(setup.Record.Changes);
Assert.Equal(Acme, setup.Record.Name);
Assert.Equal(new[] { Old }, setup.Record.Pending, StringComparer.Ordinal);
Assert.Equal(problem is "unreadable" ? 2 : 0, setup.Shell.Reads.Count);
Assert.Empty(setup.Notes);
}
/// <summary>
/// A recorded shortcut that cannot be opened, because another program holds it and shares nothing, is never judged:
/// it may well be ours. The uninstall fails with a failure that names the shortcut, before anything is deleted or
/// forgotten; run again once the file is free, it removes the shortcut and forgets the record.
/// </summary>
[Fact]
public void RemovalOfAShortcutThatCannotBeOpenedFailsWithTheRecordKeptAndCanBeRunAgain()
{
using var setup = new BrandingSetup();
string current = setup.PlantShortcut(Acme, setup.OurLink);
string earlier = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Acme;
setup.Record.Pending.Add(Old);
using(new FileStream(earlier, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
IOException refused = Assert.Throws<IOException>(setup.Rules.RemoveInstalledShortcut);
Assert.StartsWith("The shortcut \"Old Brand.lnk\" could not be read, so it cannot be known whether it belongs to this application: ", refused.Message, StringComparison.Ordinal);
}
Assert.True(File.Exists(current));
Assert.True(File.Exists(earlier));
Assert.Empty(setup.Record.Changes);
Assert.Equal(Acme, setup.Record.Name);
Assert.Equal(new[] { Old }, setup.Record.Pending, StringComparer.Ordinal);
Assert.Empty(setup.Notes);
setup.Rules.RemoveInstalledShortcut();
Assert.Empty(Directory.GetFiles(setup.Rules.ShortcutDirectory));
Assert.Null(setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Empty(setup.Notes);
}
/// <summary>
/// A shortcut of ours that cannot be deleted (here another program holds the file open) fails the uninstall and stays
/// on record, so nothing of ours is left behind unrecorded; run again once the file is free, the uninstall removes it
/// and forgets the record.
/// </summary>
[Fact]
public void RemovalThatCannotDeleteOurShortcutFailsAndCanBeRunAgain()
{
using var setup = new BrandingSetup();
string shortcut = setup.PlantShortcut(Acme, setup.OurLink);
setup.Record.Name = Acme;
using(new FileStream(shortcut, FileMode.Open, FileAccess.Read, FileShare.Read))
{
Exception failed = Assert.ThrowsAny<Exception>(setup.Rules.RemoveInstalledShortcut);
Assert.True(failed is IOException or UnauthorizedAccessException);
}
Assert.True(File.Exists(shortcut));
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Changes);
setup.Rules.RemoveInstalledShortcut();
Assert.False(File.Exists(shortcut));
Assert.Null(setup.Record.Name);
}
/// <summary>
/// A read-only mark on a shortcut of ours does not fail the uninstall: the mark is cleared once the file has been
/// found to be ours, and the shortcut is removed. A read-only file recorded beside it that is someone else's keeps
/// its mark and its content and is left behind, noted and forgotten.
/// </summary>
[Fact]
public void RemovalDeletesAReadOnlyShortcutOfOursAndLeavesSomeoneElsesMarked()
{
using var setup = new BrandingSetup();
string ours = setup.PlantShortcut(Acme, setup.OurLink);
string theirs = setup.PlantShortcut(Old, new ShortcutLink(setup.Installed, "--other", BrandingSetup.Ours));
string theirsBefore = File.ReadAllText(theirs);
File.SetAttributes(ours, FileAttributes.ReadOnly);
File.SetAttributes(theirs, FileAttributes.ReadOnly);
setup.Record.Name = Acme;
setup.Record.Pending.Add(Old);
setup.Rules.RemoveInstalledShortcut();
Assert.Equal(new[] { theirs }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.True(File.GetAttributes(theirs).HasFlag(FileAttributes.ReadOnly));
Assert.Equal(theirsBefore, File.ReadAllText(theirs));
Assert.Null(setup.Record.Name);
Assert.Empty(setup.Record.Pending);
_ = Assert.Single(setup.Notes);
}
/// <summary>
/// Each shortcut is looked at again immediately before it is deleted. Here another program replaces the first
/// recorded shortcut with one of its own after removal has judged both: the replacement is left untouched with a
/// note, the other shortcut is removed, and the record is forgotten.
/// </summary>
[Fact]
public void RemovalLooksAgainBeforeItDeletes()
{
using var setup = new BrandingSetup();
string current = setup.PlantShortcut(Acme, setup.OurLink);
string earlier = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Acme;
setup.Record.Pending.Add(Old);
var theirs = new ShortcutLink(Path.Combine(setup.Root, "other.exe"), "", "Somebody's shortcut");
setup.Shell.AfterRead = () =>
{
if(setup.Shell.Reads.Count == 2)
{ FakeShortcutShell.Plant(current, theirs); }
};
setup.Rules.RemoveInstalledShortcut();
Assert.Equal(new[] { current, earlier, current, earlier }, setup.Shell.Reads, StringComparer.Ordinal);
Assert.Equal(theirs, new FakeShortcutShell().Read(current));
Assert.False(File.Exists(earlier));
Assert.Null(setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Equal(new[] { "The recorded shortcut \"Acme Print Connector.lnk\" no longer belongs to this application. It was left unchanged and is no longer recorded." }, setup.Notes, StringComparer.Ordinal);
}
}
@@ -0,0 +1,916 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.AccessControl;
using System.Security.Principal;
using Qqq.PrintAgent.Core;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The decisions about the branded all-users shortcut, run in temporary folders against a shell, folder security and a
/// shortcut record that exist only in the test: who may brand, which folders must be trusted first, what the shortcut
/// says, when one is written, replaced or left alone, what the record names at every step, and what happens when Windows
/// refuses. Removal at uninstall is in <see cref="AgentBrandingRemovalTests"/>. No test here touches this computer's
/// Start menu, registry or install folder.
/// </summary>
public sealed class AgentBrandingShortcutsTests
{
/// <summary>The shortcut of the brand the setup starts with.</summary>
private const string Acme = "Acme Print Connector.lnk";
/// <summary>The shortcut of an earlier brand.</summary>
private const string Old = "Old Brand.lnk";
/// <summary>What a branding run does to the record when it changes nothing: the proof of changeability and the two reads.</summary>
private static readonly string[] ReadsOnly = ["require", "read-last", "read-pending"];
/// <summary>Someone who is not an administrator is refused before anything is read, checked or written, for branding and for removal.</summary>
[Fact]
public void NonAdministratorIsRefusedBeforeAnythingIsTouched()
{
using var setup = new BrandingSetup();
setup.Security.IsAdministrator = false;
Assert.Equal("Machine branding requires an elevated administrator.", Assert.Throws<UnauthorizedAccessException>(setup.Rules.ApplyInstalledBranding).Message);
Assert.Equal("Machine branding requires an elevated administrator.", Assert.Throws<UnauthorizedAccessException>(setup.Rules.RemoveInstalledShortcut).Message);
Assert.Empty(setup.Security.Reads);
Assert.Empty(setup.Record.Operations);
Assert.Empty(setup.Shell.Written);
Assert.Empty(setup.EnsuredState);
Assert.False(Directory.Exists(setup.Rules.ShortcutDirectory));
}
/// <summary>
/// The first branding run checks the machine state, the install folder and the Start menu folder, creates the shortcut
/// folder, writes the shortcut under a temporary name and moves it into place, and records its file name. The shortcut
/// launches only the installed companion, with no arguments, from its folder, with the ownership description and the
/// companion's own icon. The record is proved changeable and read before anything is written, names the new shortcut
/// before the file exists, and afterwards names it as the current one and nothing else.
/// </summary>
[Fact]
public void FirstRunCreatesTheFolderAndTheShortcutAndRecordsItsName()
{
using var setup = new BrandingSetup();
(string? Name, string[] Pending)? recordedBeforeTheWrite = null;
setup.Shell.BeforeWrite = () => recordedBeforeTheWrite = (setup.Record.Name, [.. setup.Record.Pending]);
setup.Rules.ApplyInstalledBranding();
string shortcut = setup.Shortcut(Acme);
Assert.Equal(new[] { setup.State }, setup.EnsuredState, StringComparer.Ordinal);
Assert.Equal(new[] { Path.GetDirectoryName(setup.Installed)!, setup.Programs, setup.Rules.ShortcutDirectory }, setup.Security.Reads, StringComparer.Ordinal);
(string temporary, ShortcutLink link) = Assert.Single(setup.Shell.Written);
Assert.Equal(setup.Rules.ShortcutDirectory, Path.GetDirectoryName(temporary));
Assert.Matches("^\\.qqq-[0-9a-f]{32}\\.lnk$", Path.GetFileName(temporary));
Assert.Equal(new ShortcutLink(setup.Installed, "", BrandingSetup.Ours, Path.GetDirectoryName(setup.Installed)!, setup.Installed + ",0"), link);
Assert.Equal(new[] { shortcut }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(link, new FakeShortcutShell().Read(shortcut));
Assert.Null(recordedBeforeTheWrite!.Value.Name);
Assert.Equal(new[] { Acme }, recordedBeforeTheWrite.Value.Pending, StringComparer.Ordinal);
Assert.Equal(new[] { "require", "read-last", "read-pending", "pending=" + Acme, "last=" + Acme, "clear-pending" }, setup.Record.Operations, StringComparer.Ordinal);
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Empty(setup.Notes);
}
/// <summary>
/// A shortcut folder that does not exist is created protected: owned by the administrators, cut off from inherited
/// access, with full control for SYSTEM and the administrators and read-and-run only for ordinary users, all inherited
/// by what is put inside. A folder that already exists is not created again, only checked.
/// </summary>
[Fact]
public void NewShortcutFolderIsCreatedProtectedAndAnExistingOneIsOnlyChecked()
{
using var setup = new BrandingSetup();
setup.Rules.ApplyInstalledBranding();
(string directory, DirectorySecurity descriptor) = Assert.Single(setup.Security.Created);
Assert.Equal(Path.Combine(setup.Programs, "Print Connectors"), directory);
Assert.True(descriptor.AreAccessRulesProtected);
Assert.Equal(FakePathSecurity.Administrators, descriptor.GetOwner(typeof(SecurityIdentifier))!.Value);
FileSystemAccessRule[] rules = descriptor.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)).Cast<FileSystemAccessRule>().ToArray();
Assert.Equal(new[] { "S-1-5-18:FullControl", FakePathSecurity.Administrators + ":FullControl", FakePathSecurity.Users + ":ReadAndExecute, Synchronize" },
rules.Select(rule => rule.IdentityReference.Value + ":" + rule.FileSystemRights.ToString()), StringComparer.Ordinal);
Assert.All(rules, rule =>
{
Assert.Equal(AccessControlType.Allow, rule.AccessControlType);
Assert.Equal(InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, rule.InheritanceFlags);
Assert.Equal(PropagationFlags.None, rule.PropagationFlags);
});
setup.Rules.ApplyInstalledBranding();
_ = Assert.Single(setup.Security.Created);
Assert.Equal(2, setup.Security.Reads.Count(folder => string.Equals(folder, directory, StringComparison.Ordinal)));
}
/// <summary>The shortcut is named after the customer's display name, or the product's own name when the customer set none, and uses the customer's icon when one is given.</summary>
[Fact]
public void NameAndIconComeFromTheCustomersBrandingWithTheProductAsDefault()
{
using var setup = new BrandingSetup();
setup.Branding = new RuntimeBranding(DisplayName: null, IconPath: Path.Combine(setup.State, "brand.ico"));
setup.Rules.ApplyInstalledBranding();
Assert.Equal(AppIdentity.DefaultInfo.DisplayName + ".lnk", setup.Record.Name);
Assert.Equal(new[] { true }, setup.LoadedWithInstallerLabels);
Assert.Equal(Path.Combine(setup.State, "brand.ico") + ",0", Assert.Single(setup.Shell.Written).Link.IconLocation);
}
/// <summary>A display name that is only spaces still gives a usable shortcut, under the fixed fallback name.</summary>
[Fact]
public void BlankDisplayNameFallsBackToAFixedShortcutName()
{
using var setup = new BrandingSetup();
setup.Branding = new RuntimeBranding(DisplayName: " ");
setup.Rules.ApplyInstalledBranding();
Assert.Equal("Print Connector.lnk", setup.Record.Name);
Assert.True(File.Exists(setup.Shortcut("Print Connector.lnk")));
}
/// <summary>
/// When the finished shortcut cannot be moved into place (here a folder already has the wanted name), the failure
/// reaches the caller, the temporary file is removed, the folder is left as it was, and the record is as it was
/// found: the name written ahead of the shortcut is taken out again and nothing is named as current.
/// </summary>
[Fact]
public void ShortcutThatCannotBeMovedIntoPlaceLeavesNoTemporaryFile()
{
using var setup = new BrandingSetup();
string blocking = setup.Shortcut(Acme);
Directory.CreateDirectory(blocking);
Exception failed = Assert.ThrowsAny<Exception>(setup.Rules.ApplyInstalledBranding);
Assert.True(failed is IOException or UnauthorizedAccessException);
_ = Assert.Single(setup.Shell.Written);
Assert.Empty(Directory.GetFiles(setup.Rules.ShortcutDirectory));
Assert.True(Directory.Exists(blocking));
Assert.Equal(new[] { "pending=" + Acme, "clear-pending" }, setup.Record.Changes, StringComparer.Ordinal);
Assert.Null(setup.Record.Name);
Assert.Empty(setup.Record.Pending);
}
/// <summary>
/// When the brand name changes, both names are on record before the new shortcut is written; the new shortcut is then
/// written and made the current one, and only then is the previous one, still ours, removed and forgotten. A previous
/// name that differs only in letter case is the same file and is not removed.
/// </summary>
[Fact]
public void RebrandingWritesTheNewShortcutThenRemovesThePreviousOne()
{
using var setup = new BrandingSetup();
setup.Rules.ApplyInstalledBranding();
setup.Branding = new RuntimeBranding(DisplayName: "Globex Connector");
(string? Name, string[] Pending, bool PreviousExists)? atTheWrite = null;
setup.Shell.BeforeWrite = () => atTheWrite = (setup.Record.Name, [.. setup.Record.Pending], File.Exists(setup.Shortcut(Acme)));
setup.Rules.ApplyInstalledBranding();
Assert.Equal(Acme, atTheWrite!.Value.Name);
Assert.True(atTheWrite.Value.PreviousExists);
Assert.Equal(new[] { Acme, "Globex Connector.lnk" }, atTheWrite.Value.Pending, StringComparer.Ordinal);
Assert.Equal(new[] { setup.Shortcut("Globex Connector.lnk") }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal("Globex Connector.lnk", setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Equal(new[] { "pending=" + Acme + "|Globex Connector.lnk", "last=Globex Connector.lnk", "clear-pending" }, setup.Record.Changes.Skip(3), StringComparer.Ordinal);
setup.Record.Name = "GLOBEX CONNECTOR.LNK";
setup.Rules.ApplyInstalledBranding();
Assert.True(File.Exists(setup.Shortcut("Globex Connector.lnk")));
Assert.Equal("Globex Connector.lnk", setup.Record.Name);
Assert.Empty(setup.Notes);
}
/// <summary>
/// A file with the wanted name that is not a shortcut of ours is never replaced: branding is refused, the file is
/// unchanged and the record is not changed. That holds for a shortcut someone else made (another program, arguments
/// or description), for a file that holds no shortcut at all (empty, or anything else), and for a link, which is
/// not even opened.
/// </summary>
[Theory]
[InlineData("target")]
[InlineData("arguments")]
[InlineData("description")]
[InlineData("empty")]
[InlineData("garbage")]
[InlineData("link")]
public void ForeignShortcutUnderTheWantedNameIsNotReplaced(string difference)
{
using var setup = new BrandingSetup();
string shortcut = setup.PlantShortcut(Acme, difference switch
{
"target" => new ShortcutLink(Path.Combine(setup.Root, "other.exe"), "", BrandingSetup.Ours),
"arguments" => new ShortcutLink(setup.Installed, "--tray", BrandingSetup.Ours),
"description" => new ShortcutLink(setup.Installed, "", "Somebody's shortcut"),
_ => setup.OurLink,
});
if(difference is "empty")
{ File.WriteAllBytes(shortcut, []); }
else if(difference is "garbage")
{ File.WriteAllText(shortcut, "not a shortcut at all"); }
else if(difference is "link")
{ _ = setup.Security.Links.Add(shortcut); }
string before = File.ReadAllText(shortcut);
Assert.Equal("A different shortcut already uses the requested brand name.", Assert.Throws<InvalidOperationException>(setup.Rules.ApplyInstalledBranding).Message);
Assert.Equal(before, File.ReadAllText(shortcut));
Assert.Empty(setup.Shell.Written);
Assert.Equal(difference is "link" ? 0 : 1, setup.Shell.Reads.Count);
Assert.Equal(ReadsOnly, setup.Record.Operations, StringComparer.Ordinal);
}
/// <summary>
/// A recorded file that is not a shortcut of ours is left exactly as it is and forgotten, with one note, and the run
/// succeeds: a file that holds no shortcut at all (empty, or anything else), and a link, which is not even opened,
/// because this product never writes one.
/// </summary>
[Theory]
[InlineData("empty")]
[InlineData("garbage")]
[InlineData("link")]
public void RecordedFileThatIsNoShortcutOfOursIsLeftAloneAndForgotten(string kind)
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
if(kind is "empty")
{ File.WriteAllBytes(previous, []); }
else if(kind is "garbage")
{ File.WriteAllText(previous, "not a shortcut at all"); }
else
{ _ = setup.Security.Links.Add(previous); }
string before = File.ReadAllText(previous);
setup.Record.Name = Old;
setup.Rules.ApplyInstalledBranding();
Assert.Equal(before, File.ReadAllText(previous));
Assert.True(File.Exists(setup.Shortcut(Acme)));
Assert.Equal(kind is "link" ? 0 : 1, setup.Shell.Reads.Count(path => string.Equals(path, previous, StringComparison.Ordinal)));
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Equal(new[] { "The recorded shortcut \"Old Brand.lnk\" no longer belongs to this application. It was left unchanged and is no longer recorded." }, setup.Notes, StringComparer.Ordinal);
}
/// <summary>
/// A recorded shortcut that cannot be opened, because another program holds it and shares nothing, is never judged:
/// it may well be ours. At the first look that stops the run before anything is changed, with a failure that names
/// the shortcut, and nothing is forgotten. Once the file is free, the next run removes it.
/// </summary>
[Fact]
public void RecordedShortcutThatCannotBeOpenedStopsTheRunAndIsNotForgotten()
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Old;
using(new FileStream(previous, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
IOException refused = Assert.Throws<IOException>(setup.Rules.ApplyInstalledBranding);
Assert.StartsWith("The shortcut \"Old Brand.lnk\" could not be read, so it cannot be known whether it belongs to this application: ", refused.Message, StringComparison.Ordinal);
}
Assert.Equal(new[] { previous }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Empty(setup.Shell.Written);
Assert.Equal(ReadsOnly, setup.Record.Operations, StringComparer.Ordinal);
Assert.Equal(Old, setup.Record.Name);
Assert.Empty(setup.Notes);
setup.Rules.ApplyInstalledBranding();
Assert.Equal(new[] { setup.Shortcut(Acme) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Empty(setup.Notes);
}
/// <summary>
/// A same-brand install, upgrade or repair while another program holds the current shortcut says that the shortcut
/// could not be read. It does not say that a different shortcut uses the name, which would be untrue: the shortcut
/// is ours. Nothing is changed.
/// </summary>
[Fact]
public void CurrentShortcutThatCannotBeOpenedIsReportedAsUnreadableNotAsSomeoneElses()
{
using var setup = new BrandingSetup();
string current = setup.PlantShortcut(Acme, setup.OurLink);
setup.Record.Name = Acme;
using(new FileStream(current, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
IOException refused = Assert.Throws<IOException>(setup.Rules.ApplyInstalledBranding);
Assert.StartsWith("The shortcut \"Acme Print Connector.lnk\" could not be read, so it cannot be known whether it belongs to this application: ", refused.Message, StringComparison.Ordinal);
}
Assert.Equal(setup.OurLink, new FakeShortcutShell().Read(current));
Assert.Empty(setup.Shell.Written);
Assert.Equal(ReadsOnly, setup.Record.Operations, StringComparer.Ordinal);
Assert.Equal(Acme, setup.Record.Name);
}
/// <summary>
/// When another program takes hold of the previous shortcut after the first look, the second look cannot open it. It
/// is then not judged and not deleted; like a delete that failed, its name stays on record with a note that says
/// why, the run succeeds, and the next run removes the shortcut.
/// </summary>
[Fact]
public void PreviousShortcutHeldByAnotherProgramAtTheSecondLookStaysOnRecord()
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Old;
FileStream? held = null;
setup.Shell.BeforeWrite = () => held = new FileStream(previous, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
try
{
setup.Rules.ApplyInstalledBranding();
}
finally
{
held?.Dispose();
}
Assert.True(File.Exists(previous));
Assert.Equal(Acme, setup.Record.Name);
Assert.Equal(new[] { Old }, setup.Record.Pending, StringComparer.Ordinal);
string note = Assert.Single(setup.Notes);
Assert.StartsWith("The previous shortcut \"Old Brand.lnk\" could not be removed (IOException: The shortcut \"Old Brand.lnk\" could not be read, so it cannot be known whether it belongs to this application: ", note, StringComparison.Ordinal);
Assert.EndsWith("). It stays recorded, and the next branding run or the uninstall will try again.", note, StringComparison.Ordinal);
setup.Shell.BeforeWrite = null;
setup.Rules.ApplyInstalledBranding();
Assert.False(File.Exists(previous));
Assert.Empty(setup.Record.Pending);
}
/// <summary>
/// The previous brand's shortcut is removed only while it is still ours. When the file under the recorded name has
/// become someone else's, or the recorded name is not a plain shortcut file name in the shortcut folder, the run still
/// succeeds: the new shortcut is written and recorded, nothing else is touched, and one note says what was left alone.
/// A second run then finds its own shortcut recorded and is clean: no note, no failure, the same record.
/// </summary>
[Theory]
[InlineData(Old, "The recorded shortcut \"Old Brand.lnk\" no longer belongs to this application. It was left unchanged and is no longer recorded.")]
[InlineData("..\\Old Brand.lnk", "A recorded shortcut name is not a shortcut file name in the shortcut folder. Nothing was removed for it, and it is no longer recorded.")]
public void PreviousShortcutThatIsNoLongerOursIsLeftAloneAndTheNewOneIsRecorded(string recorded, string note)
{
using var setup = new BrandingSetup();
string foreign = setup.PlantShortcut(Old, new ShortcutLink(Path.Combine(setup.Root, "other.exe"), "", BrandingSetup.Ours));
string outside = Path.Combine(setup.Programs, Old);
FakeShortcutShell.Plant(outside, setup.OurLink);
string foreignBefore = File.ReadAllText(foreign);
setup.Record.Name = recorded;
setup.Rules.ApplyInstalledBranding();
Assert.Equal(setup.OurLink, Assert.IsType<ShortcutLink>(new FakeShortcutShell().Read(setup.Shortcut(Acme))) with { WorkingDirectory = "", IconLocation = "" });
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Equal(foreignBefore, File.ReadAllText(foreign));
Assert.True(File.Exists(outside));
Assert.Equal(new[] { note }, setup.Notes, StringComparer.Ordinal);
setup.Rules.ApplyInstalledBranding();
_ = Assert.Single(setup.Notes);
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Equal(foreignBefore, File.ReadAllText(foreign));
Assert.Equal(2, Directory.GetFiles(setup.Rules.ShortcutDirectory).Length);
}
/// <summary>
/// Names left on record by an earlier run are judged like the previous brand's: one that is still ours is removed, one
/// that has become someone else's or is not a shortcut file name in the folder is left alone with a note, one whose
/// file is gone is dropped silently, and afterwards the record names only the current shortcut.
/// </summary>
[Fact]
public void NamesLeftOnRecordByAnEarlierRunAreSettled()
{
using var setup = new BrandingSetup();
_ = setup.PlantShortcut(Acme, setup.OurLink);
string ours = setup.PlantShortcut("Older Brand.lnk", setup.OurLink);
string foreign = setup.PlantShortcut(Old, new ShortcutLink(setup.Installed, "--other", BrandingSetup.Ours));
setup.Record.Name = Acme;
setup.Record.Pending.AddRange([Old, "..\\Escaping.lnk", "Gone.lnk", "Older Brand.lnk", "OLDER BRAND.LNK"]);
setup.Rules.ApplyInstalledBranding();
Assert.False(File.Exists(ours));
Assert.True(File.Exists(foreign));
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Equal(new[] { "pending=Older Brand.lnk", "last=" + Acme, "clear-pending" }, setup.Record.Changes, StringComparer.Ordinal);
Assert.Equal(new[]
{
"The recorded shortcut \"Old Brand.lnk\" no longer belongs to this application. It was left unchanged and is no longer recorded.",
"A recorded shortcut name is not a shortcut file name in the shortcut folder. Nothing was removed for it, and it is no longer recorded.",
}, setup.Notes, StringComparer.Ordinal);
}
/// <summary>
/// A run that has to fail fails before it has changed anything. Here the previous brand's shortcut cannot be read, so
/// whether it is ours cannot be known: no new shortcut is written, nothing is removed and the record still names the old one.
/// </summary>
[Fact]
public void PreviousShortcutThatCannotBeReadStopsTheRunBeforeAnythingChanges()
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Old;
setup.Shell.ReadFailure = new InvalidOperationException("Windows could not open the shortcut.");
Assert.Equal("Windows could not open the shortcut.", Assert.Throws<InvalidOperationException>(setup.Rules.ApplyInstalledBranding).Message);
Assert.Equal(new[] { previous }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Empty(setup.Shell.Written);
Assert.Equal(ReadsOnly, setup.Record.Operations, StringComparer.Ordinal);
Assert.Equal(Old, setup.Record.Name);
Assert.Empty(setup.Notes);
}
/// <summary>
/// A record that cannot be changed stops branding before anything is written or even looked at: a run that could not
/// record its shortcut afterwards must not make one.
/// </summary>
[Fact]
public void RecordThatCannotBeChangedStopsBrandingBeforeAnythingIsWritten()
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Old;
setup.Record.ChangeRefusal = new UnauthorizedAccessException("Synthetic: the record refuses change.");
Assert.Equal("Synthetic: the record refuses change.", Assert.Throws<UnauthorizedAccessException>(setup.Rules.ApplyInstalledBranding).Message);
Assert.Equal(new[] { "require" }, setup.Record.Operations, StringComparer.Ordinal);
Assert.Empty(setup.Shell.Reads);
Assert.Empty(setup.Shell.Written);
Assert.Equal(new[] { previous }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
}
/// <summary>
/// When removing the previous shortcut fails (here another program holds the file open), the run still succeeds: the
/// new shortcut is in place and current, the previous one stays on record with a note, and the next run, finding it
/// free, removes it and forgets it without another note.
/// </summary>
[Fact]
public void PreviousShortcutThatCannotBeDeletedStaysOnRecordAndTheNextRunRemovesIt()
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Old;
using(new FileStream(previous, FileMode.Open, FileAccess.Read, FileShare.Read))
{
setup.Rules.ApplyInstalledBranding();
}
Assert.Equal(Acme, setup.Record.Name);
Assert.Equal(new[] { Old }, setup.Record.Pending, StringComparer.Ordinal);
Assert.True(File.Exists(setup.Shortcut(Acme)));
Assert.True(File.Exists(previous));
string note = Assert.Single(setup.Notes);
Assert.StartsWith("The previous shortcut \"Old Brand.lnk\" could not be removed (", note, StringComparison.Ordinal);
Assert.EndsWith("). It stays recorded, and the next branding run or the uninstall will try again.", note, StringComparison.Ordinal);
setup.Rules.ApplyInstalledBranding();
Assert.False(File.Exists(previous));
Assert.Equal(new[] { setup.Shortcut(Acme) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
_ = Assert.Single(setup.Notes);
}
/// <summary>A shortcut an earlier run could not delete is still on record at uninstall, which removes it together with the current one and forgets both.</summary>
[Fact]
public void UninstallRemovesAShortcutAnEarlierRunCouldNotDelete()
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Old;
using(new FileStream(previous, FileMode.Open, FileAccess.Read, FileShare.Read))
{
setup.Rules.ApplyInstalledBranding();
}
setup.Rules.RemoveInstalledShortcut();
Assert.Empty(Directory.GetFiles(setup.Rules.ShortcutDirectory));
Assert.Null(setup.Record.Name);
Assert.Empty(setup.Record.Pending);
}
/// <summary>
/// The previous shortcut is looked at again immediately before it is deleted. Here another program replaces it with a
/// shortcut of its own while the new one is being written: the replacement is left untouched, a note says so, and the
/// name is forgotten because the file is no longer ours.
/// </summary>
[Fact]
public void PreviousShortcutReplacedAfterTheFirstLookIsNotDeleted()
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Old;
var theirs = new ShortcutLink(Path.Combine(setup.Root, "other.exe"), "", "Somebody's shortcut");
setup.Shell.BeforeWrite = () => FakeShortcutShell.Plant(previous, theirs);
setup.Rules.ApplyInstalledBranding();
Assert.Equal(theirs, new FakeShortcutShell().Read(previous));
Assert.Equal(new[] { previous, previous }, setup.Shell.Reads, StringComparer.Ordinal);
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Equal(new[] { "The recorded shortcut \"Old Brand.lnk\" no longer belongs to this application. It was left unchanged and is no longer recorded." }, setup.Notes, StringComparer.Ordinal);
}
/// <summary>A previous shortcut that something else removed after the first look needs nothing: the run succeeds, the name is forgotten and no note is written.</summary>
[Fact]
public void PreviousShortcutThatVanishedAfterTheFirstLookIsForgotten()
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Old;
setup.Shell.BeforeWrite = () => File.Delete(previous);
setup.Rules.ApplyInstalledBranding();
Assert.Equal(new[] { setup.Shortcut(Acme) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(new[] { previous }, setup.Shell.Reads, StringComparer.Ordinal);
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Empty(setup.Notes);
}
/// <summary>
/// When the previous shortcut cannot be read at the second look, it is not deleted, because it can no longer be known
/// to be ours; the run succeeds, the name stays on record with a note, and the next run removes the shortcut.
/// </summary>
[Fact]
public void PreviousShortcutThatCannotBeLookedAtAgainStaysOnRecord()
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Old;
setup.Shell.BeforeWrite = () => setup.Shell.ReadFailure = new InvalidOperationException("Windows could not open the shortcut.");
setup.Rules.ApplyInstalledBranding();
Assert.True(File.Exists(previous));
Assert.Equal(Acme, setup.Record.Name);
Assert.Equal(new[] { Old }, setup.Record.Pending, StringComparer.Ordinal);
Assert.Equal(new[] { "The previous shortcut \"Old Brand.lnk\" could not be removed (InvalidOperationException: Windows could not open the shortcut.). It stays recorded, and the next branding run or the uninstall will try again." }, setup.Notes, StringComparer.Ordinal);
setup.Shell.BeforeWrite = null;
setup.Shell.ReadFailure = null;
setup.Rules.ApplyInstalledBranding();
Assert.False(File.Exists(previous));
Assert.Empty(setup.Record.Pending);
}
/// <summary>
/// When the new shortcut is written but cannot be recorded as the current one, the run fails, yet the shortcut is not
/// lost: its name was put on record before it was written, next to the previous one. The next branding run makes it
/// current and removes the previous shortcut; an uninstall instead removes both.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ShortcutWrittenButNotMadeCurrentIsStillOnRecord(bool thenBrandAgain)
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
setup.Record.Name = Old;
setup.Record.WriteLastFailure = new IOException("Synthetic: the record could not be written.");
Assert.Equal("Synthetic: the record could not be written.", Assert.Throws<IOException>(setup.Rules.ApplyInstalledBranding).Message);
Assert.True(File.Exists(setup.Shortcut(Acme)));
Assert.True(File.Exists(previous));
Assert.Equal(Old, setup.Record.Name);
Assert.Equal(new[] { Old, Acme }, setup.Record.Pending, StringComparer.Ordinal);
setup.Record.WriteLastFailure = null;
if(thenBrandAgain)
{
setup.Rules.ApplyInstalledBranding();
Assert.Equal(new[] { setup.Shortcut(Acme) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(Acme, setup.Record.Name);
}
else
{
setup.Rules.RemoveInstalledShortcut();
Assert.Empty(Directory.GetFiles(setup.Rules.ShortcutDirectory));
Assert.Null(setup.Record.Name);
}
Assert.Empty(setup.Record.Pending);
Assert.Empty(setup.Notes);
}
/// <summary>A recorded previous shortcut that no longer exists is simply forgotten: branding succeeds and records the new name.</summary>
[Fact]
public void MissingPreviousShortcutDoesNotStopBranding()
{
using var setup = new BrandingSetup();
setup.Record.Name = Old;
setup.Rules.ApplyInstalledBranding();
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Empty(setup.Notes);
}
/// <summary>
/// Branding stops before it writes anything when a place it depends on cannot be trusted: the machine state folder, an
/// install folder an ordinary user can write to or that an untrusted account owns, a missing companion, a Start menu
/// folder with an untrusted owner or writer, or an existing shortcut folder an ordinary user can write to.
/// </summary>
[Theory]
[InlineData("state", typeof(UnauthorizedAccessException), "Synthetic untrusted machine state.")]
[InlineData("install-writable", typeof(UnauthorizedAccessException), "An update path allows modification by an untrusted identity. Repair the MSI directory permissions.")]
[InlineData("install-owner", typeof(UnauthorizedAccessException), "An update path is not owned by a trusted administrator or Windows identity.")]
[InlineData("companion-missing", typeof(FileNotFoundException), "The installed companion is missing.")]
[InlineData("menu-writable", typeof(UnauthorizedAccessException), "The shortcut directory permits untrusted content or permission changes.")]
[InlineData("menu-owner", typeof(UnauthorizedAccessException), "The shortcut directory requires a trusted owner and explicit access-control list.")]
[InlineData("folder-writable", typeof(UnauthorizedAccessException), "The shortcut directory permits untrusted content or permission changes.")]
public void UntrustedPlaceStopsBrandingBeforeAnythingIsWritten(string problem, Type failure, string message)
{
using var setup = new BrandingSetup();
string install = Path.GetDirectoryName(setup.Installed)!;
if(problem is "state")
{ setup.StateRefusal = new UnauthorizedAccessException(message); }
else if(problem is "install-writable")
{ setup.Security.Descriptors[install] = FakePathSecurity.Descriptor(alsoAllow: FakePathSecurity.Users, rights: FileSystemRights.Modify); }
else if(problem is "install-owner")
{ setup.Security.Descriptors[install] = FakePathSecurity.Descriptor(owner: FakePathSecurity.Users); }
else if(problem is "companion-missing")
{ File.Delete(setup.Installed); }
else if(problem is "menu-writable")
{ setup.Security.Descriptors[setup.Programs] = FakePathSecurity.Descriptor(alsoAllow: FakePathSecurity.Users, rights: FileSystemRights.Write); }
else if(problem is "menu-owner")
{ setup.Security.Descriptors[setup.Programs] = FakePathSecurity.Descriptor(owner: FakePathSecurity.Users); }
else
{
Directory.CreateDirectory(setup.Rules.ShortcutDirectory);
setup.Security.Descriptors[setup.Rules.ShortcutDirectory] = FakePathSecurity.Descriptor(alsoAllow: FakePathSecurity.Users, rights: FileSystemRights.Write);
}
Exception refused = Assert.Throws(failure, setup.Rules.ApplyInstalledBranding);
Assert.Equal(message, refused.Message);
Assert.Empty(setup.Shell.Written);
Assert.Empty(setup.Security.Created);
Assert.Empty(setup.Record.Operations);
Assert.Equal(problem is "folder-writable", Directory.Exists(setup.Rules.ShortcutDirectory));
Assert.Empty(Directory.GetFiles(setup.Programs, "*", SearchOption.AllDirectories));
}
/// <summary>The administrator who runs the installer may own the install folder: that account is trusted for that folder although it is not one of the fixed trusted accounts.</summary>
[Fact]
public void InstallFolderOwnedByTheCallingAdministratorIsTrusted()
{
using var setup = new BrandingSetup();
setup.Security.Descriptors[Path.GetDirectoryName(setup.Installed)!] = FakePathSecurity.Descriptor(owner: setup.Security.CurrentUserSid!);
setup.Rules.ApplyInstalledBranding();
Assert.Equal(Acme, setup.Record.Name);
}
/// <summary>
/// When the shell cannot write the shortcut, the failure reaches the caller and nothing is left: not the half-written
/// temporary file a shell that failed part way leaves behind, no shortcut under the real name, and no name on record.
/// When it cannot read an existing shortcut under the wanted name, that shortcut is not replaced.
/// </summary>
[Theory]
[InlineData("write-part-way")]
[InlineData("write-at-once")]
[InlineData("read")]
public void ShellFailureLeavesNoShortcutAndNoRecord(string failure)
{
using var setup = new BrandingSetup();
string? existing = null;
if(failure is "read")
{
existing = setup.PlantShortcut(Acme, setup.OurLink);
setup.Shell.ReadFailure = new InvalidOperationException("Windows could not open the shortcut.");
}
else
{
setup.Shell.WriteFailure = new InvalidOperationException("Windows shortcut automation is unavailable.");
setup.Shell.WriteFailsBeforeAnyFile = failure is "write-at-once";
}
InvalidOperationException failed = Assert.Throws<InvalidOperationException>(setup.Rules.ApplyInstalledBranding);
Assert.StartsWith("Windows ", failed.Message, StringComparison.Ordinal);
Assert.Equal(existing is null ? [] : new[] { existing }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Empty(setup.Shell.Written);
Assert.Equal(failure is "write-part-way" ? 1 : 0, setup.Shell.Abandoned.Count);
Assert.All(setup.Shell.Abandoned, path =>
{
Assert.Matches("^\\.qqq-[0-9a-f]{32}\\.lnk$", Path.GetFileName(path));
Assert.False(File.Exists(path));
});
Assert.Null(setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.DoesNotContain(setup.Record.Operations, operation => operation.StartsWith("last=", StringComparison.Ordinal));
}
/// <summary>
/// A rebranding whose new shortcut cannot be written leaves everything as it was found: the previous brand's shortcut
/// is still there and still the current one on record, the other names on record are the ones that were there before,
/// and no note is written about a name the run would have dropped had it succeeded.
/// </summary>
[Fact]
public void FailedWriteLeavesThePreviousBrandAsItWas()
{
using var setup = new BrandingSetup();
string previous = setup.PlantShortcut(Old, setup.OurLink);
string older = setup.PlantShortcut("Older Brand.lnk", setup.OurLink);
_ = setup.PlantShortcut("Foreign.lnk", new ShortcutLink(setup.Installed, "--other", BrandingSetup.Ours));
setup.Record.Name = Old;
setup.Record.Pending.AddRange(["Older Brand.lnk", "Foreign.lnk"]);
setup.Shell.WriteFailure = new InvalidOperationException("Windows shortcut automation is unavailable.");
_ = Assert.Throws<InvalidOperationException>(setup.Rules.ApplyInstalledBranding);
Assert.True(File.Exists(previous));
Assert.True(File.Exists(older));
Assert.False(File.Exists(setup.Shortcut(Acme)));
Assert.Equal(Old, setup.Record.Name);
Assert.Equal(new[] { "Older Brand.lnk", "Foreign.lnk" }, setup.Record.Pending, StringComparer.Ordinal);
Assert.Equal(new[] { "pending=" + Old + "|Older Brand.lnk|" + Acme, "pending=Older Brand.lnk|Foreign.lnk" }, setup.Record.Changes, StringComparer.Ordinal);
Assert.Empty(setup.Notes);
}
/// <summary>
/// When rewriting a shortcut that is already there fails, the shortcut of ours under that name stays on record, so
/// it cannot be lost: the name written ahead is taken out again only when no shortcut is under it.
/// </summary>
[Fact]
public void FailedRewriteKeepsTheExistingShortcutOnRecord()
{
using var setup = new BrandingSetup();
_ = setup.PlantShortcut(Old, setup.OurLink);
string existing = setup.PlantShortcut(Acme, setup.OurLink);
setup.Record.Name = Old;
setup.Shell.WriteFailure = new InvalidOperationException("Windows shortcut automation is unavailable.");
_ = Assert.Throws<InvalidOperationException>(setup.Rules.ApplyInstalledBranding);
Assert.True(File.Exists(existing));
Assert.Equal(Old, setup.Record.Name);
Assert.Equal(new[] { Old, Acme }, setup.Record.Pending, StringComparer.Ordinal);
}
/// <summary>
/// The destination is looked at again immediately before the new shortcut is moved into place. A shortcut of ours
/// that another program replaced with its own while the new one was being written is not overwritten: the run is
/// refused, the other program's file is exactly as it left it, and no temporary file stays behind.
/// </summary>
[Fact]
public void ShortcutOfOursReplacedBySomeoneElsesBeforeTheMoveIsNotOverwritten()
{
using var setup = new BrandingSetup();
string destination = setup.PlantShortcut(Acme, setup.OurLink);
setup.Record.Name = Acme;
var theirs = new ShortcutLink(Path.Combine(setup.Root, "other.exe"), "", "Somebody's shortcut");
setup.Shell.BeforeWrite = () => FakeShortcutShell.Plant(destination, theirs);
Assert.Equal("A different shortcut already uses the requested brand name.", Assert.Throws<InvalidOperationException>(setup.Rules.ApplyInstalledBranding).Message);
Assert.Equal(theirs, new FakeShortcutShell().Read(destination));
Assert.Equal(new[] { destination }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(new[] { destination, destination }, setup.Shell.Reads, StringComparer.Ordinal);
Assert.DoesNotContain(setup.Record.Operations, operation => operation.StartsWith("last=", StringComparison.Ordinal));
}
/// <summary>
/// When nothing had the wanted name at the first look, the new shortcut is moved into place without ever
/// overwriting. A file that appeared there while the shortcut was being written is someone else's: the run is
/// refused, that file is exactly as it was put there, no temporary file stays behind, and the record is as it was found.
/// </summary>
[Fact]
public void FileThatAppearedUnderTheWantedNameBeforeTheMoveIsNotOverwritten()
{
using var setup = new BrandingSetup();
Directory.CreateDirectory(setup.Rules.ShortcutDirectory);
var theirs = new ShortcutLink(Path.Combine(setup.Root, "other.exe"), "", "Somebody's shortcut");
setup.Shell.BeforeWrite = () => FakeShortcutShell.Plant(setup.Shortcut(Acme), theirs);
Assert.Equal("A different shortcut already uses the requested brand name.", Assert.Throws<InvalidOperationException>(setup.Rules.ApplyInstalledBranding).Message);
Assert.Equal(theirs, new FakeShortcutShell().Read(setup.Shortcut(Acme)));
Assert.Equal(new[] { setup.Shortcut(Acme) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(new[] { "pending=" + Acme, "clear-pending" }, setup.Record.Changes, StringComparer.Ordinal);
Assert.Null(setup.Record.Name);
Assert.Empty(setup.Record.Pending);
}
/// <summary>
/// When another program takes hold of our current shortcut while its replacement is being written, the second look
/// cannot open it, so it is not replaced: the run fails saying the shortcut could not be read, the shortcut is as it
/// was, no temporary file stays behind, and its name is still on record.
/// </summary>
[Fact]
public void CurrentShortcutHeldAtTheSecondLookIsNotReplaced()
{
using var setup = new BrandingSetup();
string destination = setup.PlantShortcut(Acme, setup.OurLink);
setup.Record.Name = Acme;
FileStream? held = null;
setup.Shell.BeforeWrite = () => held = new FileStream(destination, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
try
{
IOException refused = Assert.Throws<IOException>(setup.Rules.ApplyInstalledBranding);
Assert.StartsWith("The shortcut \"Acme Print Connector.lnk\" could not be read, so it cannot be known whether it belongs to this application: ", refused.Message, StringComparison.Ordinal);
}
finally
{
held?.Dispose();
}
Assert.Equal(setup.OurLink, new FakeShortcutShell().Read(destination));
Assert.Equal(new[] { destination }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.Equal(Acme, setup.Record.Name);
}
/// <summary>
/// A read-only mark on a shortcut of ours does not stop a run: the mark is cleared once the file has been found to
/// be ours, and then the shortcut is replaced by a same-brand run, or removed by a rename. Without that, every later
/// install, upgrade and repair would fail until someone cleared the mark by hand.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ReadOnlyShortcutOfOursIsReplacedOrRemoved(bool sameBrand)
{
using var setup = new BrandingSetup();
string existing = setup.PlantShortcut(sameBrand ? Acme : Old, setup.OurLink);
File.SetAttributes(existing, FileAttributes.ReadOnly);
setup.Record.Name = sameBrand ? Acme : Old;
setup.Rules.ApplyInstalledBranding();
Assert.Equal(new[] { setup.Shortcut(Acme) }, Directory.GetFiles(setup.Rules.ShortcutDirectory), StringComparer.Ordinal);
Assert.False(File.GetAttributes(setup.Shortcut(Acme)).HasFlag(FileAttributes.ReadOnly));
Assert.Equal(setup.Installed + ",0", Assert.IsType<ShortcutLink>(new FakeShortcutShell().Read(setup.Shortcut(Acme))).IconLocation);
Assert.Equal(Acme, setup.Record.Name);
Assert.Empty(setup.Record.Pending);
Assert.Empty(setup.Notes);
}
/// <summary>
/// The read-only mark is cleared only on a file that is ours. A read-only file that is someone else's keeps its mark
/// and its content, whether it sits under the wanted name, where it refuses the run, or under a recorded earlier
/// name, where it is left alone and forgotten.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ReadOnlyFileThatIsNotOursKeepsItsMark(bool underTheWantedName)
{
using var setup = new BrandingSetup();
string theirs = setup.PlantShortcut(underTheWantedName ? Acme : Old, new ShortcutLink(setup.Installed, "--other", BrandingSetup.Ours));
string before = File.ReadAllText(theirs);
File.SetAttributes(theirs, FileAttributes.ReadOnly);
setup.Record.Name = underTheWantedName ? Acme : Old;
if(underTheWantedName)
{ _ = Assert.Throws<InvalidOperationException>(setup.Rules.ApplyInstalledBranding); }
else
{ setup.Rules.ApplyInstalledBranding(); }
Assert.True(File.GetAttributes(theirs).HasFlag(FileAttributes.ReadOnly));
Assert.Equal(before, File.ReadAllText(theirs));
}
/// <summary>A note that cannot be written (the log refuses, or its disk fails) never fails a run that has done its work.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public void NoteThatCannotBeWrittenDoesNotFailTheRun(bool accessDenied)
{
using var setup = new BrandingSetup();
_ = setup.PlantShortcut(Old, new ShortcutLink(setup.Installed, "--other", BrandingSetup.Ours));
setup.Record.Name = Old;
setup.NoteFailure = accessDenied ? new UnauthorizedAccessException("Synthetic: the log refuses.") : new IOException("Synthetic: the log's disk fails.");
setup.Rules.ApplyInstalledBranding();
Assert.Equal(Acme, setup.Record.Name);
Assert.Equal(1, setup.NotesRefused);
setup.Record.Pending.Add(Old);
setup.Rules.RemoveInstalledShortcut();
Assert.Null(setup.Record.Name);
Assert.Equal(2, setup.NotesRefused);
Assert.Empty(setup.Notes);
}
}
@@ -144,7 +144,9 @@ public sealed class AgentPipeSecurityTests
string definition = Path.Combine(directory, "startup.xml");
try
{
using(FileStream held = AgentStartup.CreateHeldDefinition(definition, "<Task>synthetic</Task>"))
List<string> ensured = [];
var rules = new AgentStartupTasks(new FakeTaskScheduler(), Path.Combine(directory, "Qqq.PrintAgent.exe"), new ProtectedStaging(directory, ensured.Add, _ => { }));
using(FileStream held = rules.CreateHeldDefinition(definition, "<Task>synthetic</Task>"))
{
Assert.Contains("synthetic", File.ReadAllText(definition), StringComparison.Ordinal);
Assert.Throws<IOException>(() => File.WriteAllText(definition, "replacement"));
@@ -152,6 +154,7 @@ public sealed class AgentPipeSecurityTests
Assert.Throws<IOException>(() => File.Move(definition, Path.Combine(directory, "moved.xml")));
}
Assert.Contains("synthetic", File.ReadAllText(definition), StringComparison.Ordinal);
Assert.Equal(new[] { definition }, ensured, StringComparer.Ordinal);
}
finally
{
@@ -0,0 +1,511 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Xml.Linq;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The decisions about the sign-in startup task, run against a task scheduler that exists only in memory: who may change
/// the task, when it is created, replaced, removed or left alone, what is registered, and what is reported when Windows
/// refuses, hangs or stores something else. No test here reads or changes this computer's scheduled tasks.
/// </summary>
public sealed class AgentStartupTasksTests
{
/// <summary>The security identifier of the user the rules are asked on behalf of.</summary>
private const string Sid = FakeTaskScheduler.UserSid;
/// <summary>The name of that user's sign-in task.</summary>
private const string TaskName = "QQQ Print Agent " + Sid;
/// <summary>The XML namespace of a task definition.</summary>
private static readonly XNamespace Ns = "http://schemas.microsoft.com/windows/2004/02/mit/task";
/// <summary>Someone who is not an administrator is told so and nothing is asked of the scheduler, for the status as for a change or the uninstall cleanup.</summary>
[Fact]
public async Task NonAdministratorIsRefusedBeforeTheSchedulerIsAsked()
{
using var setup = new Setup();
setup.Scheduler.Caller = setup.Scheduler.Caller with { IsAdministrator = false };
Assert.Equal(new AgentStartupStatus(false, false, "Administrator authorization is required to configure sign-in startup."), await setup.Rules.GetStatusAsync());
UnauthorizedAccessException change = await Assert.ThrowsAsync<UnauthorizedAccessException>(() => setup.Rules.SetEnabledAsync(true));
Assert.Equal("Configure sign-in startup from the elevated installed companion.", change.Message);
UnauthorizedAccessException cleanup = await Assert.ThrowsAsync<UnauthorizedAccessException>(setup.Rules.RemoveInstalledTasksAsync);
Assert.Equal("Removing machine installation startup tasks requires administrator authorization.", cleanup.Message);
Assert.Empty(setup.Scheduler.Runs);
}
/// <summary>An identity without a security identifier cannot name a task, so the status and a change fail with that reason and nothing is asked of the scheduler.</summary>
[Fact]
public async Task CallerWithoutASecurityIdentifierCannotNameATask()
{
using var setup = new Setup();
setup.Scheduler.Caller = setup.Scheduler.Caller with { UserSid = null };
Assert.Equal("Current user has no SID.", (await Assert.ThrowsAsync<InvalidOperationException>(setup.Rules.GetStatusAsync)).Message);
Assert.Equal("Current user has no SID.", (await Assert.ThrowsAsync<InvalidOperationException>(() => setup.Rules.SetEnabledAsync(true))).Message);
Assert.Empty(setup.Scheduler.Runs);
}
/// <summary>With no task registered, the installed companion is offered the preference and a development or portable copy is told why it is not; the one query sent names this user's task.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task WithoutATaskThePreferenceIsOfferedOnlyToTheInstalledCompanion(bool installed)
{
using var setup = new Setup();
if(!installed)
{ setup.Scheduler.Caller = setup.Scheduler.Caller with { ProcessPath = Path.Combine(setup.Root, "bin", "Qqq.PrintAgent.exe") }; }
AgentStartupStatus status = await setup.Rules.GetStatusAsync();
Assert.Equal(installed
? new AgentStartupStatus(false, true, "Starts the installed companion in your tray at your next sign-in. The service already starts with Windows.")
: new AgentStartupStatus(false, false, "Enable this from the MSI-installed companion. Development and portable executables cannot create an elevated sign-in task."), status);
Assert.Equal(new[] { "/Query", "/TN", TaskName, "/XML" }, Assert.Single(setup.Scheduler.Runs));
Assert.Equal(TimeSpan.FromSeconds(15), setup.Scheduler.LastLimit);
}
/// <summary>A registered task of ours is reported enabled unless its settings say it is disabled, and an enabled task keeps the preference available even to a copy that could not create it, so it can still be turned off.</summary>
[Fact]
public async Task OwnTaskIsReportedEnabledOrDisabledAsItsSettingsSay()
{
using var setup = new Setup();
setup.Scheduler.Tasks[TaskName] = setup.Rules.CreateTaskXml(Sid);
Assert.True((await setup.Rules.GetStatusAsync()).Enabled);
setup.Scheduler.Caller = setup.Scheduler.Caller with { ProcessPath = Path.Combine(setup.Root, "bin", "Qqq.PrintAgent.exe") };
AgentStartupStatus fromACopy = await setup.Rules.GetStatusAsync();
Assert.True(fromACopy.Enabled);
Assert.True(fromACopy.Available);
var disabled = XDocument.Parse(setup.Rules.CreateTaskXml(Sid));
disabled.Root!.Element(Ns + "Settings")!.Element(Ns + "Enabled")!.Value = "False";
setup.Scheduler.Tasks[TaskName] = disabled.ToString();
AgentStartupStatus off = await setup.Rules.GetStatusAsync();
Assert.False(off.Enabled);
Assert.False(off.Available);
}
/// <summary>A task under this user's task name that is not exactly ours is reported as someone else's, never as enabled, and neither enabling nor disabling touches it.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task ForeignTaskUnderOurNameIsReportedAndLeftUnchanged(bool enable)
{
using var setup = new Setup();
string foreign = setup.Rules.CreateTaskXml(Sid).Replace("--tray", "--something-else", StringComparison.Ordinal);
setup.Scheduler.Tasks[TaskName] = foreign;
Assert.Equal(new AgentStartupStatus(false, false, "A different task definition uses this startup name. Inspect it in Windows Task Scheduler before changing this preference."), await setup.Rules.GetStatusAsync());
InvalidOperationException refused = await Assert.ThrowsAsync<InvalidOperationException>(() => setup.Rules.SetEnabledAsync(enable));
Assert.Equal("A different task definition uses this startup name. It was left unchanged; inspect it in Windows Task Scheduler.", refused.Message);
Assert.All(setup.Scheduler.Runs, run => Assert.Equal("/Query", run[0]));
Assert.Equal(foreign, setup.Scheduler.Tasks[TaskName]);
Assert.False(Directory.Exists(setup.SetupDirectory));
}
/// <summary>
/// Enabling from the installed companion registers exactly the generated definition for this user: it is staged in the
/// setup folder as UTF-16 with a byte-order mark, held so it cannot be swapped while the scheduler reads it, registered
/// with replacement allowed, read back and checked, and the staged file is gone afterwards. The commands are query,
/// create, query to verify, query for the status.
/// </summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task EnablingRegistersTheGeneratedDefinitionAndVerifiesIt(bool replacesOurEarlierTask)
{
using var setup = new Setup();
if(replacesOurEarlierTask)
{ setup.Scheduler.Tasks[TaskName] = setup.Rules.CreateTaskXml(Sid).Replace("<Enabled>true</Enabled>", "<Enabled>false</Enabled>", StringComparison.Ordinal); }
AgentStartupStatus status = await setup.Rules.SetEnabledAsync(true);
Assert.Equal(new AgentStartupStatus(true, true, "Starts the installed companion in your tray at your next sign-in. The service already starts with Windows."), status);
Assert.Equal(new[] { "/Query", "/Create", "/Query", "/Query" }, setup.Scheduler.Runs.Select(run => run[0]), StringComparer.Ordinal);
(string path, string text, byte[] start) = Assert.NotNull(setup.Scheduler.Definition);
Assert.Equal(new[] { "/Create", "/TN", TaskName, "/XML", path, "/F" }, setup.Scheduler.Runs[1]);
Assert.Equal(setup.SetupDirectory, Path.GetDirectoryName(path));
Assert.Matches("^startup-[0-9a-f]{32}\\.xml$", Path.GetFileName(path));
Assert.Equal(setup.Rules.CreateTaskXml(Sid), text);
Assert.Equal(new byte[] { 0xFF, 0xFE }, start);
Assert.True(setup.Scheduler.DefinitionWasHeld);
Assert.Equal(new[] { setup.SetupDirectory, path }, setup.EnsuredFolders, StringComparer.Ordinal);
Assert.Equal(new[] { path }, setup.ValidatedFiles, StringComparer.Ordinal);
Assert.Equal(setup.Rules.CreateTaskXml(Sid), setup.Scheduler.Tasks[TaskName]);
Assert.Empty(Directory.GetFiles(setup.SetupDirectory));
}
/// <summary>A development or portable copy cannot enable the elevated task: it is refused after the one query, and nothing is staged or registered.</summary>
[Fact]
public async Task EnablingIsRefusedForACopyThatIsNotTheInstalledCompanion()
{
using var setup = new Setup();
setup.Scheduler.Caller = setup.Scheduler.Caller with { ProcessPath = Path.Combine(setup.Root, "bin", "Qqq.PrintAgent.exe") };
InvalidOperationException refused = await Assert.ThrowsAsync<InvalidOperationException>(() => setup.Rules.SetEnabledAsync(true));
Assert.Equal("Only the installed Program Files companion can enable elevated sign-in startup.", refused.Message);
Assert.Equal("/Query", Assert.Single(setup.Scheduler.Runs)[0]);
Assert.Empty(setup.Scheduler.Tasks);
Assert.False(Directory.Exists(setup.SetupDirectory));
}
/// <summary>
/// The definition is registered only from a place that passed both checks. When the setup folder cannot be trusted
/// nothing is written; when the staged file does not have a trusted owner and access list it is not handed to the
/// scheduler and is removed. In both cases the refusal reaches the caller and no task is registered.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task UntrustedStagingStopsRegistrationBeforeTheSchedulerSeesTheDefinition(bool folderUntrusted)
{
using var setup = new Setup();
if(folderUntrusted)
{ setup.FolderRefusal = new UnauthorizedAccessException("Synthetic untrusted folder."); }
else
{ setup.FileRefusal = new UnauthorizedAccessException("Synthetic untrusted file."); }
UnauthorizedAccessException refused = await Assert.ThrowsAsync<UnauthorizedAccessException>(() => setup.Rules.SetEnabledAsync(true));
Assert.StartsWith("Synthetic untrusted", refused.Message, StringComparison.Ordinal);
Assert.Equal("/Query", Assert.Single(setup.Scheduler.Runs)[0]);
Assert.Empty(setup.Scheduler.Tasks);
if(folderUntrusted)
{
Assert.False(Directory.Exists(setup.SetupDirectory));
}
else
{
Assert.Equal(setup.SetupDirectory, Path.GetDirectoryName(Assert.Single(setup.ValidatedFiles)));
Assert.Empty(Directory.GetFiles(setup.SetupDirectory));
}
}
/// <summary>When Windows refuses to register the task, that is reported, nothing is read back, and the staged definition is removed.</summary>
[Fact]
public async Task RefusedRegistrationIsReportedAndTheStagedDefinitionRemoved()
{
using var setup = new Setup();
setup.Scheduler.Refuses["/Create"] = 1;
InvalidOperationException failed = await Assert.ThrowsAsync<InvalidOperationException>(() => setup.Rules.SetEnabledAsync(true));
Assert.Equal("Windows could not register the sign-in task. Check Task Scheduler and administrator permissions.", failed.Message);
Assert.Equal(new[] { "/Query", "/Create" }, setup.Scheduler.Runs.Select(run => run[0]), StringComparer.Ordinal);
Assert.Empty(Directory.GetFiles(setup.SetupDirectory));
}
/// <summary>
/// A task that Windows stored differently from the definition it was given, or that cannot be read back at all, is not
/// trusted: enabling fails with the verification message, the unexpected task is left as Windows stored it, and the
/// staged definition is removed.
/// </summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RegisteredTaskThatDoesNotReadBackAsOursIsReported(bool storedDifferently)
{
using var setup = new Setup();
if(storedDifferently)
{ setup.Scheduler.StoreAs = definition => definition.Replace("HighestAvailable", "LeastPrivilege", StringComparison.Ordinal); }
else
{ setup.Scheduler.Unreadable.Add(TaskName); }
InvalidOperationException failed = await Assert.ThrowsAsync<InvalidOperationException>(() => setup.Rules.SetEnabledAsync(true));
Assert.Equal("The registered startup task could not be verified. Inspect Windows Task Scheduler; an unexpected task was left unchanged.", failed.Message);
Assert.Equal(new[] { "/Query", "/Create", "/Query" }, setup.Scheduler.Runs.Select(run => run[0]), StringComparer.Ordinal);
Assert.True(setup.Scheduler.Tasks.ContainsKey(TaskName));
Assert.Empty(Directory.GetFiles(setup.SetupDirectory));
}
/// <summary>Disabling deletes our task with one forced delete of exactly its name and reports the preference off; with no task there is nothing to delete and no delete is sent.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task DisablingDeletesOnlyOurOwnTask(bool registered)
{
using var setup = new Setup();
if(registered)
{ setup.Scheduler.Tasks[TaskName] = setup.Rules.CreateTaskXml(Sid); }
AgentStartupStatus status = await setup.Rules.SetEnabledAsync(false);
Assert.False(status.Enabled);
Assert.Empty(setup.Scheduler.Tasks);
string[][] deletes = setup.Scheduler.Runs.Where(run => run[0] is "/Delete").ToArray();
if(registered)
{ Assert.Equal(new[] { "/Delete", "/TN", TaskName, "/F" }, Assert.Single(deletes)); }
else
{ Assert.Empty(deletes); }
Assert.DoesNotContain(setup.Scheduler.Runs, run => run[0] is "/Create");
}
/// <summary>When Windows refuses to delete the task, disabling says so instead of reporting the preference off.</summary>
[Fact]
public async Task RefusedDeletionIsReported()
{
using var setup = new Setup();
setup.Scheduler.Tasks[TaskName] = setup.Rules.CreateTaskXml(Sid);
setup.Scheduler.Refuses["/Delete"] = 5;
InvalidOperationException failed = await Assert.ThrowsAsync<InvalidOperationException>(() => setup.Rules.SetEnabledAsync(false));
Assert.Equal("Windows could not remove the sign-in task. Open Task Scheduler to inspect it.", failed.Message);
Assert.True(setup.Scheduler.Tasks.ContainsKey(TaskName));
}
/// <summary>A scheduler that cannot be started, or that hangs and is stopped at the 15 second limit, is reported as such; the timeout carries the cancellation that ended the wait.</summary>
[Fact]
public async Task SchedulerThatCannotStartOrHangsIsReported()
{
using var setup = new Setup();
setup.Scheduler.CannotStart = true;
Assert.Equal("Could not start Windows Task Scheduler administration.", (await Assert.ThrowsAsync<InvalidOperationException>(setup.Rules.GetStatusAsync)).Message);
setup.Scheduler.CannotStart = false;
setup.Scheduler.HangsOn = "/Query";
TimeoutException hung = await Assert.ThrowsAsync<TimeoutException>(() => setup.Rules.SetEnabledAsync(true));
Assert.Equal("Windows Task Scheduler did not respond within 15 seconds. Refresh the preference to check its current state.", hung.Message);
_ = Assert.IsType<OperationCanceledException>(hung.InnerException);
Assert.Equal(TimeSpan.FromSeconds(15), setup.Scheduler.LastLimit);
}
/// <summary>
/// Uninstall removes every task that is exactly ours, for any user, and nothing else. Left alone: a task that only
/// starts with our prefix, a line whose name is not closed by a quote, a task under one of our names with someone
/// else's definition, one whose definition cannot be read, and unrelated tasks.
/// </summary>
[Fact]
public async Task UninstallRemovesExactlyTheTasksThatAreOurs()
{
const string otherSid = "S-1-5-21-100-200-300-1002";
const string foreignSid = "S-1-5-21-100-200-300-1003";
const string unreadableSid = "S-1-5-21-100-200-300-1004";
using var setup = new Setup();
setup.Scheduler.Tasks[TaskName] = setup.Rules.CreateTaskXml(Sid);
setup.Scheduler.Tasks["QQQ Print Agent " + otherSid] = setup.Rules.CreateTaskXml(otherSid);
setup.Scheduler.Tasks["QQQ Print Agent " + foreignSid] = setup.Rules.CreateTaskXml(foreignSid).Replace("--tray", "--other", StringComparison.Ordinal);
setup.Scheduler.Tasks["QQQ Print Agent " + unreadableSid] = setup.Rules.CreateTaskXml(unreadableSid);
setup.Scheduler.Unreadable.Add("QQQ Print Agent " + unreadableSid);
setup.Scheduler.Tasks["QQQ Print Agent backup"] = setup.Rules.CreateTaskXml(Sid);
setup.Scheduler.Tasks["Microsoft\\Windows\\Defrag\\ScheduledDefrag"] = "<Task />";
setup.Scheduler.ExtraListing.Add("\"\\QQQ Print Agent " + Sid);
await setup.Rules.RemoveInstalledTasksAsync();
Assert.Equal(new[] { "Microsoft\\Windows\\Defrag\\ScheduledDefrag", "QQQ Print Agent " + foreignSid, "QQQ Print Agent " + unreadableSid, "QQQ Print Agent backup" }, setup.Scheduler.Tasks.Keys.Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Equal(new[] { "/Query", "/FO", "CSV", "/NH" }, setup.Scheduler.Runs[0]);
Assert.Equal(new[] { "\\" + TaskName, "\\QQQ Print Agent " + otherSid }, setup.Scheduler.Runs.Where(run => run[0] is "/Delete").Select(run => run[2]), StringComparer.Ordinal);
Assert.All(setup.Scheduler.Runs.Where(run => run[0] is "/Delete"), run => Assert.Equal(new[] { "/Delete", "/TN", run[2], "/F" }, run));
Assert.DoesNotContain(setup.Scheduler.Runs, run => run.Contains("\\QQQ Print Agent backup"));
}
/// <summary>Uninstall stops with a clear failure when the task list cannot be read, and when a task verified as ours cannot be deleted.</summary>
[Fact]
public async Task UninstallReportsAListThatCannotBeReadAndATaskThatCannotBeDeleted()
{
using var setup = new Setup();
setup.Scheduler.Tasks[TaskName] = setup.Rules.CreateTaskXml(Sid);
setup.Scheduler.Refuses["/Delete"] = 1;
Assert.Equal("Could not remove a verified QQQ Print Agent startup task.", (await Assert.ThrowsAsync<InvalidOperationException>(setup.Rules.RemoveInstalledTasksAsync)).Message);
setup.Scheduler.Refuses["/Query"] = 1;
Assert.Equal("Could not inspect startup tasks during uninstall.", (await Assert.ThrowsAsync<InvalidOperationException>(setup.Rules.RemoveInstalledTasksAsync)).Message);
Assert.True(setup.Scheduler.Tasks.ContainsKey(TaskName));
}
/// <summary>
/// Windows may store an account name where the task was registered with a security identifier. The task is ours when
/// that name stands for exactly the identifier in the task's name; a name that stands for another account, a name that
/// stands for none, and a different identifier written out are all refused, and an identifier is never looked up as a name.
/// </summary>
[Fact]
public void AccountNameInATaskMustStandForTheIdentifierInItsName()
{
using var setup = new Setup();
setup.Scheduler.Accounts["WORKSTATION\\operator"] = Sid;
setup.Scheduler.Accounts["WORKSTATION\\someone"] = "S-1-5-21-100-200-300-2000";
Assert.True(setup.Rules.IsOwnedTaskDefinition("\\" + TaskName, WithUser(setup, "WORKSTATION\\operator")));
Assert.False(setup.Rules.IsOwnedTaskDefinition("\\" + TaskName, WithUser(setup, "WORKSTATION\\someone")));
Assert.False(setup.Rules.IsOwnedTaskDefinition("\\" + TaskName, WithUser(setup, "WORKSTATION\\nobody")));
Assert.False(setup.Rules.IsOwnedTaskDefinition("\\" + TaskName, WithUser(setup, " ")));
// The name is asked about once for the principal and once for the trigger; a refusal at the principal ends the check.
Assert.Equal(new[] { "WORKSTATION\\operator", "WORKSTATION\\operator", "WORKSTATION\\someone", "WORKSTATION\\nobody" }, setup.Scheduler.Resolved, StringComparer.Ordinal);
Assert.False(setup.Rules.IsOwnedTaskDefinition("\\" + TaskName, WithUser(setup, "S-1-5-21-100-200-300-2000")));
Assert.Equal(4, setup.Scheduler.Resolved.Count);
}
/// <summary>
/// Only the real installed companion counts as installed: the exact install path, an existing file, and no link at the
/// file, its folder or the product folder. A missing file, a folder where the program should be, another path, no path,
/// a link at any of the three levels and a parent that cannot be found are all refused.
/// </summary>
[Fact]
public void OnlyTheLinkFreeProgramAtTheInstallPathIsTheInstalledCompanion()
{
using var setup = new Setup();
string application = Path.GetDirectoryName(setup.Installed)!;
string product = Path.GetDirectoryName(application)!;
Assert.True(setup.Rules.IsInstalledCompanion(setup.Installed));
Assert.True(setup.Rules.IsInstalledCompanion(setup.Installed.ToUpperInvariant()));
Assert.False(setup.Rules.IsInstalledCompanion(null));
Assert.False(setup.Rules.IsInstalledCompanion(Path.Combine(setup.Root, "elsewhere", "Qqq.PrintAgent.exe")));
foreach(string link in new[] { setup.Installed, application, product })
{
FileAttributes before = setup.Scheduler.Paths[link];
setup.Scheduler.Paths[link] = before | FileAttributes.ReparsePoint;
Assert.False(setup.Rules.IsInstalledCompanion(setup.Installed));
setup.Scheduler.Paths[link] = before;
}
foreach(string missing in new[] { setup.Installed, application, product })
{
FileAttributes before = setup.Scheduler.Paths[missing];
_ = setup.Scheduler.Paths.Remove(missing);
Assert.False(setup.Rules.IsInstalledCompanion(setup.Installed));
setup.Scheduler.Paths[missing] = before;
}
setup.Scheduler.Paths[setup.Installed] = FileAttributes.Directory;
Assert.False(setup.Rules.IsInstalledCompanion(setup.Installed));
}
/// <summary>
/// Every command the rules send, whether it queries, creates, deletes or lists, is run with the task scheduler's
/// program by its full path in the Windows system folder and never by a bare name, so a program called schtasks
/// somewhere else on the search path can never be started with administrator rights in its place.
/// </summary>
[Fact]
public async Task EveryCommandRunsTheSchedulerFromTheSystemFolder()
{
using var setup = new Setup();
_ = await setup.Rules.SetEnabledAsync(true);
_ = await setup.Rules.SetEnabledAsync(false);
setup.Scheduler.Tasks[TaskName] = setup.Rules.CreateTaskXml(Sid);
await setup.Rules.RemoveInstalledTasksAsync();
Assert.Equal(new[] { "/Create", "/Delete", "/Query" }, setup.Scheduler.Runs.Select(run => run[0]).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Contains(setup.Scheduler.Runs, run => run.Contains("/FO", StringComparer.Ordinal));
Assert.Equal(setup.Scheduler.Runs.Count, setup.Scheduler.Programs.Count);
Assert.All(setup.Scheduler.Programs, program =>
{
Assert.True(Path.IsPathFullyQualified(program));
Assert.Equal(Environment.GetFolderPath(Environment.SpecialFolder.System), Path.GetDirectoryName(program), ignoreCase: true);
Assert.Equal("schtasks.exe", Path.GetFileName(program));
});
Assert.Equal(AgentStartupTasks.SchedulerProgram, Assert.Single(setup.Scheduler.Programs.Distinct(StringComparer.Ordinal)));
}
/// <summary>The generated definition with the user in both places replaced by the given account text.</summary>
private static string WithUser(Setup setup, string account)
{
var document = XDocument.Parse(setup.Rules.CreateTaskXml(Sid));
Assert.Single(document.Descendants(Ns + "Principal")).Element(Ns + "UserId")!.Value = account;
Assert.Single(document.Descendants(Ns + "LogonTrigger")).Element(Ns + "UserId")!.Value = account;
return document.ToString();
}
/// <summary>The rules under test with an in-memory scheduler, an install path and a setup folder under a temporary root, and an administrator running the installed companion as the caller.</summary>
private sealed class Setup : IDisposable
{
/// <summary>The temporary root everything in this setup lives under; only the setup folder is ever created.</summary>
internal string Root { get; } = Path.Combine(Path.GetTempPath(), "qqq-startup-rules-" + Guid.NewGuid().ToString("N"));
/// <summary>The path the rules treat as the installed companion.</summary>
internal string Installed { get; }
/// <summary>The folder task definitions are staged in.</summary>
internal string SetupDirectory { get; }
/// <summary>The in-memory scheduler the rules talk to.</summary>
internal FakeTaskScheduler Scheduler { get; } = new();
/// <summary>The rules under test.</summary>
internal AgentStartupTasks Rules { get; }
/// <summary>The folders the rules asked to have checked as protected state, in order.</summary>
internal List<string> EnsuredFolders { get; } = [];
/// <summary>The staged files the rules asked to have checked, in order.</summary>
internal List<string> ValidatedFiles { get; } = [];
/// <summary>When set, the folder check refuses with this.</summary>
internal Exception? FolderRefusal { get; set; }
/// <summary>When set, the staged-file check refuses with this.</summary>
internal Exception? FileRefusal { get; set; }
/// <summary>Describes an installed, link-free companion to the fake and makes the caller that companion.</summary>
internal Setup()
{
Installed = Path.Combine(Root, "QQQ Print Agent", "App", "Qqq.PrintAgent.exe");
SetupDirectory = Path.Combine(Root, "setup");
Scheduler.Paths[Installed] = FileAttributes.Archive;
Scheduler.Paths[Path.GetDirectoryName(Installed)!] = FileAttributes.Directory;
Scheduler.Paths[Path.GetDirectoryName(Path.GetDirectoryName(Installed)!)!] = FileAttributes.Directory;
Scheduler.Caller = new StartupCaller(Sid, IsAdministrator: true, ProcessPath: Installed);
Rules = new AgentStartupTasks(Scheduler, Installed, new ProtectedStaging(SetupDirectory, folder =>
{
EnsuredFolders.Add(folder);
if(FolderRefusal is not null)
{ throw FolderRefusal; }
}, file =>
{
ValidatedFiles.Add(file);
if(FileRefusal is not null)
{ throw FileRefusal; }
}));
}
/// <summary>Removes the temporary root if a test created the setup folder.</summary>
public void Dispose()
{
if(Directory.Exists(Root))
{ Directory.Delete(Root, recursive: true); }
}
}
}
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// What the installed product binds its branding rules to. The factory is the only place the real folders and the
/// machine state guard's check are chosen, so this pins that the check is the guard's own and that the state folder is
/// the one the guard protects. Nothing is created, read or checked on this computer: the factory only names places and
/// checks, and no check is called.
/// </summary>
public sealed class BrandingPlacesTests
{
/// <summary>
/// Branding is bound to the companion under Program Files, the all-users Start menu, the machine state folder with
/// the guard's own check of it, and the machine's branding; each note goes to the given log marked as the installer's.
/// </summary>
[Fact]
public void InstalledProductIsBoundToTheRealPlacesAndTheMachineStateGuard()
{
List<string> log = [];
var places = BrandingPlaces.Installed(log.Add);
Assert.Equal(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "QQQ Print Agent", "App", "Qqq.PrintAgent.exe"), places.InstalledExecutable);
Assert.Equal(Environment.GetFolderPath(Environment.SpecialFolder.CommonPrograms), places.StartMenuPrograms);
Assert.Equal(MachineStateDirectory.Root, places.StateDirectory);
Assert.Equal<Action<string>>(MachineStateDirectory.EnsureForPath, places.EnsureProtectedState);
Assert.Equal<Func<bool, RuntimeBranding>>(RuntimeBranding.LoadMachine, places.LoadBranding);
Assert.Empty(log);
places.Note("Something was left alone.");
Assert.Equal(new[] { "[Installer] Something was left alone." }, log, StringComparer.Ordinal);
}
/// <summary>
/// A note written the careful way reaches the log like any other, and when the log refuses it or its disk fails the
/// caller is not failed: an install or an uninstall that has done its work is not undone by its log. Any other
/// failure of the log is not hidden.
/// </summary>
[Fact]
public void NoteThatCannotBeWrittenDoesNotFailTheCaller()
{
List<string> log = [];
Exception? failure = null;
var places = new BrandingPlaces("companion", "programs", "state", _ => { }, _ => new RuntimeBranding(), note =>
{
if(failure is not null)
{ throw failure; }
log.Add(note);
});
places.TryNote("first");
failure = new UnauthorizedAccessException("Synthetic: the log refuses.");
places.TryNote("refused");
failure = new IOException("Synthetic: the log's disk fails.");
places.TryNote("lost");
failure = new InvalidOperationException("Synthetic: something else.");
Assert.Same(failure, Assert.Throws<InvalidOperationException>(() => places.TryNote("not hidden")));
Assert.Equal(new[] { "first" }, log, StringComparer.Ordinal);
}
}
@@ -0,0 +1,176 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The branding rules under test in temporary folders: an install folder with a companion file, a Start menu programs
/// folder, a machine state folder, and fakes for the shell, folder security and the shortcut record. Everything lives
/// under one temporary root that is removed afterwards; nothing on this computer's Start menu or registry is touched.
/// </summary>
internal sealed class BrandingSetup : IDisposable
{
/// <summary>The description that marks a shortcut as this application's.</summary>
internal const string Ours = "QQQ Print Agent companion";
/// <summary>The temporary root everything in this setup lives under.</summary>
internal string Root { get; } = Path.Combine(Path.GetTempPath(), "qqq-branding-rules-" + Guid.NewGuid().ToString("N"));
/// <summary>The companion the shortcut must launch; an ordinary file in the temporary install folder.</summary>
internal string Installed { get; }
/// <summary>The stand-in for the all-users Start menu programs folder.</summary>
internal string Programs { get; }
/// <summary>The stand-in for the machine state folder.</summary>
internal string State { get; }
/// <summary>The shell fake.</summary>
internal FakeShortcutShell Shell { get; } = new();
/// <summary>The folder security fake.</summary>
internal FakePathSecurity Security { get; } = new();
/// <summary>The shortcut record fake.</summary>
internal FakeShortcutRecord Record { get; } = new();
/// <summary>The branding the rules load; a customer display name and no icon unless a test changes it.</summary>
internal RuntimeBranding Branding { get; set; } = new(DisplayName: "Acme Print Connector");
/// <summary>The notes the rules wrote, in order, in place of the machine activity log.</summary>
internal List<string> Notes { get; } = [];
/// <summary>When set, the log cannot be written: every note fails with this.</summary>
internal Exception? NoteFailure { get; set; }
/// <summary>How many notes the log refused.</summary>
internal int NotesRefused { get; private set; }
/// <summary>For each time the branding was loaded, whether the installer's labels were asked for.</summary>
internal List<bool> LoadedWithInstallerLabels { get; } = [];
/// <summary>The folders the rules asked to have checked as protected machine state.</summary>
internal List<string> EnsuredState { get; } = [];
/// <summary>When set, the machine state check refuses with this.</summary>
internal Exception? StateRefusal { get; set; }
/// <summary>The rules under test.</summary>
internal AgentBrandingShortcuts Rules { get; }
/// <summary>What a shortcut of ours says, as far as ownership goes: the installed companion, no arguments, the ownership description.</summary>
internal ShortcutLink OurLink => new(Installed, "", Ours);
/// <summary>
/// Creates the install folder with a companion file and the programs folder, and binds the rules to them and to the
/// fakes. A test that wants the rules to meet Windows itself passes the real shortcut shell, which then reads and
/// writes real shortcut files in the temporary shortcut folder in place of the shell fake.
/// </summary>
internal BrandingSetup(IShortcutShell? realShell = null)
{
Installed = Path.Combine(Root, "Program Files", "QQQ Print Agent", "App", "Qqq.PrintAgent.exe");
Programs = Path.Combine(Root, "Start Menu", "Programs");
State = Path.Combine(Root, "state");
Directory.CreateDirectory(Path.GetDirectoryName(Installed)!);
File.WriteAllText(Installed, "synthetic companion");
Directory.CreateDirectory(Programs);
Rules = new AgentBrandingShortcuts(realShell ?? Shell, Security, Record, new BrandingPlaces(Installed, Programs, State, EnsureState, LoadBranding, WriteNote));
}
/// <summary>The path a shortcut of the given file name has in the shortcut folder.</summary>
internal string Shortcut(string filename) => Path.Combine(Rules.ShortcutDirectory, filename);
/// <summary>Puts a shortcut file in the shortcut folder as some other program would have, creating the folder if needed, and returns its path.</summary>
internal string PlantShortcut(string filename, ShortcutLink link)
{
Directory.CreateDirectory(Rules.ShortcutDirectory);
string path = Shortcut(filename);
FakeShortcutShell.Plant(path, link);
return path;
}
/// <summary>Removes the temporary root, first clearing the read-only mark from any file a test put it on.</summary>
public void Dispose()
{
foreach(string file in Directory.GetFiles(Root, "*", SearchOption.AllDirectories))
{ File.SetAttributes(file, FileAttributes.Normal); }
Directory.Delete(Root, recursive: true);
}
/// <summary>Stands in for the machine state check: notes the folder and refuses when the test asked it to.</summary>
private void EnsureState(string folder)
{
EnsuredState.Add(folder);
if(StateRefusal is not null)
{ throw StateRefusal; }
}
/// <summary>Stands in for the branding loader: notes how it was asked and returns the test's branding.</summary>
private RuntimeBranding LoadBranding(bool includeInstallerLabels)
{
LoadedWithInstallerLabels.Add(includeInstallerLabels);
return Branding;
}
/// <summary>Stands in for the machine activity log: keeps the note, or refuses it when the test asked it to.</summary>
private void WriteNote(string note)
{
if(NoteFailure is not null)
{
NotesRefused++;
throw NoteFailure;
}
Notes.Add(note);
}
}
@@ -0,0 +1,141 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.AccessControl;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// A browser callback carries a one-time code, so the helper hands it only to the companion installed beside it, and only
/// when both programs sit under Program Files in folders nobody but trusted accounts can change. These tests run that
/// check in a temporary stand-in for Program Files with folder and file security described by a fake.
/// </summary>
public sealed class BrowserCallbackInstallationTests
{
/// <summary>
/// From the installed layout the companion next to the helper is returned, after the security of every level from each
/// program up to Program Files itself has been read: the file, its folder, the product folder and Program Files.
/// </summary>
[Fact]
public void InstalledHelperIsGivenTheCompanionBesideItAfterEveryLevelIsChecked()
{
using var setup = new Setup();
Assert.Equal(setup.Companion, QqqBrowserCallback.InstalledCompanionPath(setup.Helper, setup.ProgramFiles, setup.Security));
string product = Path.Combine(setup.ProgramFiles, "QQQ Print Agent");
Assert.Equal(new[]
{
setup.Helper, Path.Combine(product, "Cli"), product, setup.ProgramFiles,
setup.Companion, Path.Combine(product, "App"), product, setup.ProgramFiles,
}, setup.Security.Reads, StringComparer.Ordinal);
}
/// <summary>A process whose own path cannot be determined is refused, as is a helper that does not run from the installed layout; nothing is read for either.</summary>
[Fact]
public void UnknownOrMisplacedHelperIsRefusedBeforeAnythingIsRead()
{
using var setup = new Setup();
Assert.Equal("Cannot locate the installed callback helper.", Assert.Throws<InvalidOperationException>(() => QqqBrowserCallback.InstalledCompanionPath(null, setup.ProgramFiles, setup.Security)).Message);
Assert.Equal("Browser callbacks require the installed CLI location.", Assert.Throws<UnauthorizedAccessException>(() => QqqBrowserCallback.InstalledCompanionPath(Path.Combine(setup.ProgramFiles, "QQQ Print Agent", "qqq-print-agent.exe"), setup.ProgramFiles, setup.Security)).Message);
Assert.Empty(setup.Security.Reads);
}
/// <summary>A helper with the right layout that is not under Program Files is refused: the layout alone proves nothing about who could have put it there.</summary>
[Fact]
public void HelperOutsideProgramFilesIsRefused()
{
using var setup = new Setup();
string elsewhere = Path.Combine(setup.Root, "Downloads", "QQQ Print Agent", "Cli", "qqq-print-agent.exe");
_ = Assert.Throws<InvalidDataException>(() => QqqBrowserCallback.InstalledCompanionPath(elsewhere, setup.ProgramFiles, setup.Security));
Assert.Empty(setup.Security.Reads);
}
/// <summary>
/// The callback is not handed over when an account that is not trusted may change, or owns, the helper, the companion or
/// any folder above them up to Program Files, or when the companion is not there at all.
/// </summary>
[Theory]
[InlineData("helper-writable")]
[InlineData("companion-owner")]
[InlineData("application-folder-writable")]
[InlineData("product-folder-writable")]
[InlineData("program-files-writable")]
[InlineData("companion-missing")]
public void UntrustedLevelStopsTheHandOver(string problem)
{
using var setup = new Setup();
string product = Path.Combine(setup.ProgramFiles, "QQQ Print Agent");
DirectorySecurity writable = FakePathSecurity.Descriptor(alsoAllow: FakePathSecurity.Users, rights: FileSystemRights.Modify);
if(problem is "helper-writable")
{ setup.Security.Descriptors[setup.Helper] = writable; }
else if(problem is "companion-owner")
{ setup.Security.Descriptors[setup.Companion] = FakePathSecurity.Descriptor(owner: FakePathSecurity.Users); }
else if(problem is "application-folder-writable")
{ setup.Security.Descriptors[Path.Combine(product, "App")] = writable; }
else if(problem is "product-folder-writable")
{ setup.Security.Descriptors[product] = writable; }
else if(problem is "program-files-writable")
{ setup.Security.Descriptors[setup.ProgramFiles] = writable; }
else
{ File.Delete(setup.Companion); }
Exception refused = Assert.ThrowsAny<Exception>(() => QqqBrowserCallback.InstalledCompanionPath(setup.Helper, setup.ProgramFiles, setup.Security));
Assert.True(problem is "companion-missing" ? refused is FileNotFoundException : refused is UnauthorizedAccessException);
}
/// <summary>A temporary stand-in for Program Files holding the installed layout (a helper in Cli, a companion in App), with trusted security unless a test says otherwise.</summary>
private sealed class Setup : IDisposable
{
/// <summary>The temporary root.</summary>
internal string Root { get; } = Path.Combine(Path.GetTempPath(), "qqq-callback-install-" + Guid.NewGuid().ToString("N"));
/// <summary>The stand-in for Program Files.</summary>
internal string ProgramFiles { get; }
/// <summary>The callback helper in its installed place.</summary>
internal string Helper { get; }
/// <summary>The companion in its installed place.</summary>
internal string Companion { get; }
/// <summary>Folder and file security as the test describes it.</summary>
internal FakePathSecurity Security { get; } = new();
/// <summary>Creates the two folders and the two program files.</summary>
internal Setup()
{
ProgramFiles = Path.Combine(Root, "Program Files");
Helper = Path.Combine(ProgramFiles, "QQQ Print Agent", "Cli", "qqq-print-agent.exe");
Companion = Path.Combine(ProgramFiles, "QQQ Print Agent", "App", "Qqq.PrintAgent.exe");
foreach(string program in new[] { Helper, Companion })
{
Directory.CreateDirectory(Path.GetDirectoryName(program)!);
File.WriteAllText(program, "synthetic program");
}
}
/// <summary>Removes the temporary root.</summary>
public void Dispose() => Directory.Delete(Root, recursive: true);
}
}
@@ -0,0 +1,302 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The browser hand-off trace is written by a helper the browser starts and read back into the activity log, so it must
/// never hold the callback address or its one-time code, must never stop the hand-off when it cannot be written, and must
/// stay small. These tests run the trace rules and the handler's recording against a trace file held in memory.
/// </summary>
public sealed class BrowserCallbackTraceTests
{
/// <summary>A synthetic authorization code that must never appear in the trace.</summary>
private const string CanaryCode = "canary-code-7f3a91c2d4";
/// <summary>A synthetic state value that must never appear in the trace.</summary>
private const string CanaryState = "canary-state-55be07a1";
/// <summary>A callback address that carries both canaries in its query.</summary>
private static readonly string Callback = QqqBrowserCallback.RedirectUri + "?code=" + CanaryCode + "&state=" + CanaryState;
/// <summary>A zone five and a half hours ahead of universal time that never changes its clocks, so no local time in these tests is skipped or repeated.</summary>
private static readonly TimeZoneInfo Zone = TimeZoneInfo.CreateCustomTimeZone("QQQ test zone", new TimeSpan(5, 30, 0), "QQQ test zone", "QQQ test zone");
/// <summary>The instant every clock in these tests starts at: 01:45:30.250 on 5 March 2026 in the test zone, plus nine tenths of a millisecond that a stamp drops.</summary>
private static readonly DateTimeOffset Start = new DateTimeOffset(2026, 3, 4, 20, 15, 30, 250, TimeSpan.Zero).AddTicks(9000);
/// <summary>
/// A callback carrying a canary authorization code and state is forwarded through the handler's real client path to a
/// real inbox. The trace records the start with the callback's length, the steps of the forwarding and the delivery,
/// every line stamped with the clock's local time, and neither the code, nor the state, nor the query appears anywhere in it.
/// </summary>
[Fact]
public async Task DeliveredCallbackIsTracedByLengthAndFixedTextOnly()
{
ManualTimeProvider clock = Clock();
var file = new FakeTraceFile();
string name = "QqqPrintAgent.Auth.Test." + Guid.NewGuid().ToString("N");
using QqqBrowserCallback inbox = new(name);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
Task<Uri> receiving = inbox.ReceiveAsync(timeout.Token);
await QqqBrowserCallback.ForwardFromHandlerAsync(Callback, new BrowserCallbackTrace(file, clock), steps => QqqBrowserCallback.ForwardAsync(Callback, name, Environment.ProcessPath!, timeout.Token, steps));
Assert.Equal("?code=" + CanaryCode + "&state=" + CanaryState, (await receiving).Query);
string stamp = clock.GetLocalNow().ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture) + " ";
Assert.All(file.Lines, line => Assert.StartsWith(stamp, line, StringComparison.Ordinal));
Assert.StartsWith(stamp + "Protocol handler started by the browser (process " + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + ", session ", file.Lines[0], StringComparison.Ordinal);
Assert.EndsWith("); callback length " + Callback.Length.ToString(CultureInfo.InvariantCulture) + ".", file.Lines[0], StringComparison.Ordinal);
Assert.Contains(file.Lines, line => line.EndsWith("Callback address accepted: " + QqqBrowserCallback.RedirectUri + " with " + (Callback.Length - QqqBrowserCallback.RedirectUri.Length - 1).ToString(CultureInfo.InvariantCulture) + " characters of query (not logged).", StringComparison.Ordinal));
Assert.Contains(file.Lines, line => line.Contains("Companion verified (process ", StringComparison.Ordinal));
Assert.Equal(stamp + "Callback delivered: the companion acknowledged it.", file.Lines[^1]);
AssertNoCanary(file);
}
/// <summary>
/// When forwarding fails, the trace says so with the kind and message of the failure and of its cause, the failure is
/// passed on unchanged, and nothing says the callback was delivered. Shown for a callback that is refused before anything
/// connects although it carries the canary code, for an inbox that belongs to another program, and for a failure with a cause.
/// </summary>
[Theory]
[InlineData("refused")]
[InlineData("impostor")]
[InlineData("cause")]
public async Task FailedForwardingIsTracedWithItsReasonAndPassedOn(string failure)
{
var file = new FakeTraceFile();
var trace = new BrowserCallbackTrace(file, Clock());
string name = "QqqPrintAgent.Auth.Test." + Guid.NewGuid().ToString("N");
using QqqBrowserCallback inbox = new(name);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20));
string forged = "https://evil.invalid/oauth/callback?code=" + CanaryCode + "&state=" + CanaryState;
var withCause = new InvalidDataException("The active sign-in attempt did not accept the callback.", new IOException("Synthetic broken pipe."));
Exception thrown = failure switch
{
"refused" => await Assert.ThrowsAsync<InvalidDataException>(() => QqqBrowserCallback.ForwardFromHandlerAsync(forged, trace, steps => QqqBrowserCallback.ForwardAsync(forged, name, Environment.ProcessPath!, timeout.Token, steps))),
"impostor" => await Assert.ThrowsAsync<UnauthorizedAccessException>(() => QqqBrowserCallback.ForwardFromHandlerAsync(Callback, trace, steps => QqqBrowserCallback.ForwardAsync(Callback, name, Path.Combine(Path.GetTempPath(), "Qqq.PrintAgent.exe"), timeout.Token, steps))),
_ => await Assert.ThrowsAsync<InvalidDataException>(() => QqqBrowserCallback.ForwardFromHandlerAsync(Callback, trace, _ => Task.FromException(withCause))),
};
string last = file.Lines[^1];
Assert.Contains("Callback NOT delivered: " + thrown.GetType().Name + ": " + thrown.Message, last, StringComparison.Ordinal);
if(failure is "cause")
{
Assert.Same(withCause, thrown);
Assert.EndsWith("Callback NOT delivered: InvalidDataException: The active sign-in attempt did not accept the callback. (IOException: Synthetic broken pipe.)", last, StringComparison.Ordinal);
}
else
{
Assert.EndsWith(thrown.Message, last, StringComparison.Ordinal);
}
Assert.DoesNotContain(file.Lines, line => line.Contains("Callback delivered", StringComparison.Ordinal));
AssertNoCanary(file);
}
/// <summary>A missing callback is refused before anything is traced or forwarded.</summary>
[Fact]
public async Task MissingCallbackIsRefusedBeforeAnythingIsTraced()
{
var file = new FakeTraceFile();
bool forwarded = false;
_ = await Assert.ThrowsAsync<ArgumentNullException>(() => QqqBrowserCallback.ForwardFromHandlerAsync(null!, new BrowserCallbackTrace(file, Clock()), _ =>
{
forwarded = true;
return Task.CompletedTask;
}));
Assert.False(forwarded);
Assert.Empty(file.Lines);
}
/// <summary>A trace that has grown past 256 KiB is deleted before the next entry is written, so the file stays small; at the limit itself it is kept.</summary>
[Theory]
[InlineData(BrowserCallbackTrace.Limit, false)]
[InlineData(BrowserCallbackTrace.Limit + 1, true)]
public void TraceIsStartedAfreshOnlyOnceItIsOverItsLimit(long size, bool startedAfresh)
{
var file = new FakeTraceFile { Length = size };
file.Lines.Add("an earlier entry");
new BrowserCallbackTrace(file, Clock()).Write("a new entry");
Assert.Equal(256 * 1024, BrowserCallbackTrace.Limit);
Assert.Equal(startedAfresh ? 1 : 0, file.Deleted);
Assert.Equal(startedAfresh ? 1 : 2, file.Lines.Count);
Assert.EndsWith(" a new entry", file.Lines[^1], StringComparison.Ordinal);
}
/// <summary>A trace that cannot be written (the disk fails, or access is denied) never stops the hand-off: the entry is dropped and the forwarding still happens and succeeds.</summary>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task TraceThatCannotBeWrittenDoesNotStopTheHandOff(bool accessDenied)
{
var file = new FakeTraceFile { AppendFailure = accessDenied ? new UnauthorizedAccessException("Synthetic denied.") : new IOException("Synthetic disk failure.") };
bool forwarded = false;
await QqqBrowserCallback.ForwardFromHandlerAsync(Callback, new BrowserCallbackTrace(file, Clock()), _ =>
{
forwarded = true;
return Task.CompletedTask;
});
Assert.True(forwarded);
Assert.Empty(file.Lines);
}
/// <summary>
/// The recent trace is the entries stamped inside the window, oldest first: older entries, lines without a timestamp and
/// lines too short to hold one are left out, and an entry one second inside the window is included.
/// </summary>
[Fact]
public void RecentTraceIsTheStampedEntriesInsideTheWindow()
{
ManualTimeProvider clock = Clock();
var file = new FakeTraceFile();
var trace = new BrowserCallbackTrace(file, clock);
trace.Write("too old");
clock.Advance(TimeSpan.FromMinutes(5));
trace.Write("just inside the window");
file.Lines.Add("no timestamp on this line at all, only text");
file.Lines.Add("short");
clock.Advance(TimeSpan.FromMinutes(9));
trace.Write("recent");
clock.Advance(TimeSpan.FromSeconds(59));
IReadOnlyList<string> recent = trace.Recent(TimeSpan.FromMinutes(10));
Assert.Equal(new[] { file.Lines[1], file.Lines[4] }, recent, StringComparer.Ordinal);
Assert.EndsWith(" just inside the window", recent[0], StringComparison.Ordinal);
Assert.EndsWith(" recent", recent[1], StringComparison.Ordinal);
}
/// <summary>
/// An entry written exactly one window ago is still recent, and one millisecond later it is not. Entries are stamped
/// to the millisecond, so the start of the window is compared at the same precision; otherwise the fraction of a
/// millisecond the stamp drops would put such an entry outside its own window. It holds for a window of whole
/// milliseconds and for one with a fraction of a millisecond, because the start of the window is rounded down
/// after the window is taken off the present time, not before.
/// </summary>
[Theory]
[InlineData(0)]
[InlineData(5000)]
public void EntryExactlyOneWindowOldIsStillRecent(long extraTicks)
{
TimeSpan window = TimeSpan.FromMinutes(10) + TimeSpan.FromTicks(extraTicks);
ManualTimeProvider clock = Clock();
var file = new FakeTraceFile();
var trace = new BrowserCallbackTrace(file, clock);
trace.Write("at the start of the window");
Assert.Equal("2026-03-05 01:45:30.250 at the start of the window", Assert.Single(file.Lines));
clock.Advance(window);
Assert.EndsWith(" at the start of the window", Assert.Single(trace.Recent(window)), StringComparison.Ordinal);
clock.Advance(TimeSpan.FromMilliseconds(1));
Assert.Empty(trace.Recent(window));
}
/// <summary>
/// Stamps are read in the zone of the clock that wrote them, not in the zone the machine is set to: an entry stamped
/// with the clock's local time is recent on any machine, and a line stamped at a time that cannot be turned into an
/// instant at all (the first moment of the calendar, in a zone ahead of universal time) is simply not recent.
/// </summary>
[Fact]
public void StampsAreReadInTheClocksOwnZone()
{
var file = new FakeTraceFile();
var trace = new BrowserCallbackTrace(file, Clock());
file.Lines.Add("2026-03-05 01:45:00.000 stamped half a minute ago in the clock's zone");
file.Lines.Add("2026-03-04 20:15:00.000 stamped at the same reading of universal time, five and a half hours earlier");
file.Lines.Add("0001-01-01 00:00:00.000 stamped before any instant the zone can express");
Assert.Equal(new[] { file.Lines[0] }, trace.Recent(TimeSpan.FromMinutes(10)), StringComparer.Ordinal);
}
/// <summary>At most the newest 40 entries are returned, however many fall inside the window.</summary>
[Fact]
public void RecentTraceReturnsAtMostTheNewestForty()
{
ManualTimeProvider clock = Clock();
var file = new FakeTraceFile();
var trace = new BrowserCallbackTrace(file, clock);
for(int entry = 1; entry <= 45; entry++)
{
trace.Write("entry " + entry.ToString(CultureInfo.InvariantCulture));
clock.Advance(TimeSpan.FromSeconds(1));
}
IReadOnlyList<string> recent = trace.Recent(TimeSpan.FromMinutes(10));
Assert.Equal(40, BrowserCallbackTrace.MostRecent);
Assert.Equal(file.Lines.Skip(5), recent, StringComparer.Ordinal);
Assert.Equal(45, trace.Recent(TimeSpan.FromMinutes(10)).Count + 5);
}
/// <summary>A trace that does not exist has no recent entries, and one that cannot be read (the disk fails part way, or access is denied) is reported as empty instead of failing the caller.</summary>
[Theory]
[InlineData("missing")]
[InlineData("disk")]
[InlineData("denied")]
public void TraceThatIsMissingOrUnreadableHasNoRecentEntries(string state)
{
var file = new FakeTraceFile();
var trace = new BrowserCallbackTrace(file, Clock());
if(state is not "missing")
{
trace.Write("an entry");
file.ReadFailure = state is "disk" ? new IOException("Synthetic disk failure.") : new UnauthorizedAccessException("Synthetic denied.");
}
Assert.Empty(trace.Recent(TimeSpan.FromMinutes(10)));
}
/// <summary>The trace lives in the user's own local application data, where a helper started by the browser without elevation can write it.</summary>
[Fact]
public void TraceFileIsInTheUsersLocalApplicationData()
=> Assert.Equal(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "QqqPrintAgent", "auth-callback.log"), QqqBrowserCallback.TracePath);
/// <summary>
/// A clock that starts at a fixed instant in a fixed zone. The trace stamps entries in local time and reads the stamps
/// back, so a clock started at the real time in the machine's zone would make these tests depend on when and where
/// they run: in the hour a daylight-saving change repeats, an entry reads as an hour newer than it is.
/// </summary>
private static ManualTimeProvider Clock() => new(Start, Zone);
/// <summary>No line of the trace holds the canary code, the canary state, or a query string.</summary>
private static void AssertNoCanary(FakeTraceFile file)
{
Assert.NotEmpty(file.Lines);
Assert.All(file.Lines, line =>
{
Assert.DoesNotContain(CanaryCode, line, StringComparison.Ordinal);
Assert.DoesNotContain(CanaryState, line, StringComparison.Ordinal);
Assert.DoesNotContain("?code", line, StringComparison.Ordinal);
Assert.DoesNotContain("evil.invalid", line, StringComparison.Ordinal);
});
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>The trace file on disk, in a temporary folder: it reports no size and no lines until something is written, creates its folder on the first line, keeps lines in order and can be deleted.</summary>
public sealed class DiskTraceFileTests
{
/// <summary>A file that does not exist yet has size zero and no lines, and deleting it is not an error; the first line creates the folder and the file; later lines follow in order; deleting leaves nothing.</summary>
[Fact]
public void LinesAreKeptInOrderInAFileCreatedOnFirstUse()
{
string root = Path.Combine(Path.GetTempPath(), "qqq-trace-file-" + Guid.NewGuid().ToString("N"));
string path = Path.Combine(root, "nested", "auth-callback.log");
try
{
var file = new DiskTraceFile(path);
Assert.Equal(0, file.Length);
Assert.Empty(file.ReadLines());
file.Append("first");
file.Append("second");
Assert.Equal(new[] { "first", "second" }, file.ReadLines(), StringComparer.Ordinal);
Assert.Equal("first" + Environment.NewLine + "second" + Environment.NewLine, File.ReadAllText(path));
Assert.Equal(new FileInfo(path).Length, file.Length);
file.Delete();
Assert.False(File.Exists(path));
Assert.Equal(0, file.Length);
file.Delete();
}
finally
{
Directory.Delete(root, recursive: true);
}
}
}
@@ -0,0 +1,105 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Security.AccessControl;
using System.Security.Principal;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// Folder ownership and access lists as a test describes them. A folder has the descriptor the test gave it, or a trusted
/// one when the test said nothing; a folder the rules create is made as an ordinary folder on disk and remembered with the
/// descriptor the rules asked for, so a test can check that descriptor. Nothing is read from Windows.
/// </summary>
internal sealed class FakePathSecurity : IPathSecurity
{
/// <summary>The well-known security identifier of the administrators group.</summary>
internal const string Administrators = "S-1-5-32-544";
/// <summary>The well-known security identifier of ordinary users.</summary>
internal const string Users = "S-1-5-32-545";
/// <summary>Whether the caller holds administrator rights; true unless a test says otherwise.</summary>
public bool IsAdministrator { get; set; } = true;
/// <summary>The caller's security identifier.</summary>
public string? CurrentUserSid { get; set; } = "S-1-5-21-100-200-300-1001";
/// <summary>Descriptors the test set for particular folders or files.</summary>
internal Dictionary<string, FileSystemSecurity> Descriptors { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Every folder or file whose descriptor was read, in order.</summary>
internal List<string> Reads { get; } = [];
/// <summary>Paths the test says have become links; nothing on disk is changed to make them so.</summary>
internal HashSet<string> Links { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Every folder created, in order, with the descriptor asked for.</summary>
internal List<(string Directory, DirectorySecurity Security)> Created { get; } = [];
/// <summary>A descriptor owned by the given account, protected from inheritance, giving the administrators full control and, optionally, one more account the given rights.</summary>
internal static DirectorySecurity Descriptor(string owner = Administrators, string? alsoAllow = null, FileSystemRights rights = FileSystemRights.ReadAndExecute)
{
DirectorySecurity descriptor = new();
descriptor.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
descriptor.SetOwner(new SecurityIdentifier(owner));
descriptor.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(Administrators), FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
if(alsoAllow is not null)
{ descriptor.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(alsoAllow), rights, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); }
return descriptor;
}
/// <summary>Returns the descriptor the test set for the folder, or a trusted one.</summary>
public FileSystemSecurity ReadDirectorySecurity(string directory)
{
Reads.Add(directory);
return Descriptors.TryGetValue(directory, out FileSystemSecurity? descriptor) ? descriptor : Descriptor();
}
/// <summary>Returns the descriptor the test set for the file, or a trusted one; a file that does not exist cannot be read, as on a real disk.</summary>
public FileSystemSecurity ReadFileSecurity(string file)
{
Reads.Add(file);
if(!File.Exists(file))
{ throw new FileNotFoundException("Synthetic: no such file to read a security descriptor from.", file); }
return Descriptors.TryGetValue(file, out FileSystemSecurity? descriptor) ? descriptor : Descriptor();
}
/// <summary>Reports a link where the test said there is one.</summary>
public bool IsLink(string path) => Links.Contains(path);
/// <summary>Makes the folder on disk and remembers the descriptor it was to be created with.</summary>
public void CreateDirectory(string directory, DirectorySecurity security)
{
Created.Add((directory, security));
Descriptors[directory] = security;
_ = Directory.CreateDirectory(directory);
}
}
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The record of the shortcuts this installer may own, held in memory instead of the registry. Every access is written
/// down in order, and the record can be made to refuse change, or to fail when the current name is written, the way the
/// registry can.
/// </summary>
internal sealed class FakeShortcutRecord : IShortcutRecord
{
/// <summary>The recorded name of the current shortcut, or null when none is recorded.</summary>
internal string? Name { get; set; }
/// <summary>The recorded names of the other shortcuts.</summary>
internal List<string> Pending { get; } = [];
/// <summary>
/// Every access in order: "require", "read-last", "read-pending", "last=" with the name written, "pending=" with the
/// names written joined by a bar, "clear-last" and "clear-pending".
/// </summary>
internal List<string> Operations { get; } = [];
/// <summary>The accesses that changed the record, in order: everything but the proof of changeability and the reads.</summary>
internal IEnumerable<string> Changes => Operations.Where(operation => operation is not ("require" or "read-last" or "read-pending"));
/// <summary>When set, the record cannot be changed: proving changeability fails with this.</summary>
internal Exception? ChangeRefusal { get; set; }
/// <summary>When set, writing the name of the current shortcut fails with this and records nothing.</summary>
internal Exception? WriteLastFailure { get; set; }
/// <summary>Notes that it was asked, and refuses when the test said the record cannot be changed.</summary>
public void RequireChangeable()
{
Operations.Add("require");
if(ChangeRefusal is not null)
{ throw ChangeRefusal; }
}
/// <summary>Returns the recorded name of the current shortcut.</summary>
public string? ReadLastShortcutName()
{
Operations.Add("read-last");
return Name;
}
/// <summary>Returns a copy of the other recorded names.</summary>
public IReadOnlyList<string> ReadPendingShortcutNames()
{
Operations.Add("read-pending");
return [.. Pending];
}
/// <summary>Records the name of the current shortcut, or fails as asked.</summary>
public void WriteLastShortcutName(string filename)
{
Operations.Add("last=" + filename);
if(WriteLastFailure is not null)
{ throw WriteLastFailure; }
Name = filename;
}
/// <summary>Replaces the other recorded names; an empty list is refused, as the contract says it is never passed.</summary>
public void WritePendingShortcutNames(IReadOnlyList<string> filenames)
{
Operations.Add("pending=" + string.Join('|', filenames));
if(filenames.Count == 0)
{ throw new ArgumentException("The rules must clear the other names instead of writing an empty list.", nameof(filenames)); }
Pending.Clear();
Pending.AddRange(filenames);
}
/// <summary>Forgets the name of the current shortcut.</summary>
public void ClearLastShortcutName()
{
Operations.Add("clear-last");
Name = null;
}
/// <summary>Forgets the other names.</summary>
public void ClearPendingShortcutNames()
{
Operations.Add("clear-pending");
Pending.Clear();
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// Shortcut files without the Windows shell. A shortcut is written as a small text file that holds what the shortcut says,
/// so it survives being moved or replaced like the real file does, and is read back from that text. Every read and write
/// is recorded. Reading fails or finds no shortcut where the real shell would, and can also be made to fail; writing can be made to fail either before any file exists or part way,
/// leaving a broken file behind as the real shell can. A test can also act between the steps of the rules under test,
/// as another program on the same computer could.
/// </summary>
internal sealed class FakeShortcutShell : IShortcutShell
{
/// <summary>Every shortcut written, in order, with the path it was written to.</summary>
internal List<(string Path, ShortcutLink Link)> Written { get; } = [];
/// <summary>Every path read, in order.</summary>
internal List<string> Reads { get; } = [];
/// <summary>Every path at which a failed write left a broken file behind.</summary>
internal List<string> Abandoned { get; } = [];
/// <summary>When set, writing a shortcut fails with this.</summary>
internal Exception? WriteFailure { get; set; }
/// <summary>Whether a failing write gives up before it has created any file. By default it fails part way and leaves a broken file.</summary>
internal bool WriteFailsBeforeAnyFile { get; set; }
/// <summary>When set, reading a shortcut fails with this.</summary>
internal Exception? ReadFailure { get; set; }
/// <summary>When set, only reading this path fails; every other shortcut can still be read.</summary>
internal string? ReadFailureOn { get; set; }
/// <summary>Something that happens just before the shell writes: a test uses it to see what was recorded by then, or to change a file as another program would.</summary>
internal Action? BeforeWrite { get; set; }
/// <summary>Something that happens just after a failing write left its broken file behind, given that file's path: a test uses it to take hold of the file as another program would.</summary>
internal Action<string>? AfterAbandoning { get; set; }
/// <summary>Something that happens just after the shell read a shortcut: a test uses it to change a file between two looks at it.</summary>
internal Action? AfterRead { get; set; }
/// <summary>Puts a shortcut file on disk as some other program would have, without recording it as written by the rules under test.</summary>
internal static void Plant(string path, ShortcutLink link) => File.WriteAllText(path, JsonSerializer.Serialize(link));
/// <summary>
/// Reads what the file at the path says, as the real shell does: the file is opened for reading, shared for reading
/// only, so a file another handle holds exclusively fails here as it does on Windows; a file that opens and does
/// not hold a shortcut (an empty file, or anything that is not this fake's text) is null.
/// </summary>
public ShortcutLink? Read(string path)
{
Reads.Add(path);
if(ReadFailure is not null && (ReadFailureOn is null || string.Equals(ReadFailureOn, path, StringComparison.OrdinalIgnoreCase)))
{ throw ReadFailure; }
ShortcutLink? link;
using(var reader = new StreamReader(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)))
{
try
{ link = JsonSerializer.Deserialize<ShortcutLink>(reader.ReadToEnd()); }
catch(JsonException)
{ link = null; }
}
AfterRead?.Invoke();
return link;
}
/// <summary>Writes the file and records what it says, or fails as asked, leaving half a file unless told to fail earlier.</summary>
public void Write(string path, ShortcutLink link)
{
BeforeWrite?.Invoke();
if(WriteFailure is not null)
{
if(!WriteFailsBeforeAnyFile)
{
string whole = JsonSerializer.Serialize(link);
File.WriteAllText(path, whole[..(whole.Length / 2)]);
Abandoned.Add(path);
AfterAbandoning?.Invoke(path);
}
throw WriteFailure;
}
Written.Add((path, link));
Plant(path, link);
}
}
@@ -0,0 +1,171 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// A task scheduler that exists only in memory. It answers the four commands the startup rules send (query one task, list
/// all, create from a definition file, delete) from a dictionary of registered tasks, records every command it was sent,
/// and can be told to fail in each way the real one can: not start, hang, refuse a command, or store a task differently
/// from the definition it was given. Nothing on this computer is read or changed.
/// </summary>
internal sealed class FakeTaskScheduler : ITaskScheduler
{
/// <summary>The user the rules are asked on behalf of in these tests.</summary>
internal const string UserSid = "S-1-5-21-100-200-300-1001";
/// <summary>Who is asking; an administrator running the installed companion unless a test says otherwise.</summary>
internal StartupCaller Caller { get; set; } = new(UserSid, IsAdministrator: true, ProcessPath: null);
/// <summary>The registered tasks: name (without the leading backslash) to definition.</summary>
internal Dictionary<string, string> Tasks { get; } = new(StringComparer.Ordinal);
/// <summary>Lines the task list prints in addition to one line per registered task.</summary>
internal List<string> ExtraListing { get; } = [];
/// <summary>Account names this computer knows, with the security identifier each stands for.</summary>
internal Dictionary<string, string> Accounts { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>What exists on disk, as far as the rules may ask: path to attributes.</summary>
internal Dictionary<string, FileAttributes> Paths { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Every command sent, in order, as its argument list.</summary>
internal List<string[]> Runs { get; } = [];
/// <summary>The program each command was to be run with, in order.</summary>
internal List<string> Programs { get; } = [];
/// <summary>The time limit given with the last command.</summary>
internal TimeSpan? LastLimit { get; private set; }
/// <summary>Account names the rules asked to have resolved.</summary>
internal List<string> Resolved { get; } = [];
/// <summary>When true, the program cannot be started at all.</summary>
internal bool CannotStart { get; set; }
/// <summary>A command (its first argument, such as "/Create") that hangs and is stopped at the limit.</summary>
internal string? HangsOn { get; set; }
/// <summary>Exit codes to answer a command with instead of carrying it out, by first argument.</summary>
internal Dictionary<string, int> Refuses { get; } = new(StringComparer.Ordinal);
/// <summary>Task names whose definition cannot be read although the task is listed.</summary>
internal HashSet<string> Unreadable { get; } = new(StringComparer.Ordinal);
/// <summary>Changes a definition as it is stored, the way Windows may rewrite one; unchanged by default.</summary>
internal Func<string, string> StoreAs { get; set; } = definition => definition;
/// <summary>The path, text and first bytes of the definition file handed to the last create command, read while the command ran.</summary>
internal (string Path, string Text, byte[] Start)? Definition { get; private set; }
/// <summary>True when the definition file could not be deleted or rewritten while the create command ran.</summary>
internal bool DefinitionWasHeld { get; private set; }
/// <summary>Returns the caller the test configured.</summary>
public StartupCaller CurrentCaller() => Caller;
/// <summary>Records the command and answers it from the registered tasks, or fails the way the test asked.</summary>
public Task<TaskSchedulerRun?> RunAsync(string program, IReadOnlyList<string> arguments, TimeSpan limit)
{
Programs.Add(program);
Runs.Add([.. arguments]);
LastLimit = limit;
if(CannotStart)
{ return Task.FromResult<TaskSchedulerRun?>(null); }
if(string.Equals(HangsOn, arguments[0], StringComparison.Ordinal))
{ return Task.FromResult<TaskSchedulerRun?>(new TaskSchedulerRun(0, "", new OperationCanceledException("Synthetic scheduler hang."))); }
if(Refuses.TryGetValue(arguments[0], out int refusal))
{ return Task.FromResult<TaskSchedulerRun?>(new TaskSchedulerRun(refusal, "")); }
return Task.FromResult<TaskSchedulerRun?>(arguments[0] switch
{
"/Query" when arguments.Contains("/FO", StringComparer.Ordinal) => new TaskSchedulerRun(0, string.Join('\n', Tasks.Keys.Select(name => $"\"\\{name}\",\"N/A\",\"Ready\"\r").Concat(ExtraListing))),
"/Query" => Query(arguments[2]),
"/Create" => Create(arguments[2], arguments[4]),
"/Delete" => new TaskSchedulerRun(Tasks.Remove(arguments[2].TrimStart('\\')) ? 0 : 1, ""),
_ => throw new InvalidOperationException("The startup rules sent a command this fake does not know: " + arguments[0]),
});
}
/// <summary>Answers a query for one task: its definition, or exit code 1 when there is none or it cannot be read.</summary>
private TaskSchedulerRun Query(string name)
{
string key = name.TrimStart('\\');
return Tasks.TryGetValue(key, out string? definition) && !Unreadable.Contains(key) ? new TaskSchedulerRun(0, definition) : new TaskSchedulerRun(1, "");
}
/// <summary>Registers a task from its definition file, noting what the file held and whether it was protected from change while being read.</summary>
private TaskSchedulerRun Create(string name, string definitionPath)
{
Definition = (definitionPath, File.ReadAllText(definitionPath), File.ReadAllBytes(definitionPath)[..2]);
try
{
File.Delete(definitionPath);
DefinitionWasHeld = false;
}
catch(IOException)
{
DefinitionWasHeld = true;
}
Tasks[name.TrimStart('\\')] = StoreAs(Definition.Value.Text);
return new TaskSchedulerRun(0, "");
}
/// <summary>Resolves a name the test registered, and notes that it was asked.</summary>
public string? ResolveAccountSid(string account)
{
Resolved.Add(account);
return Accounts.GetValueOrDefault(account);
}
/// <summary>Reports what the test said exists at the path.</summary>
public FileAttributes? AttributesOf(string path) => Paths.TryGetValue(path, out FileAttributes attributes) ? attributes : null;
}
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>A trace file held in memory: its lines, a size the test can set, and failures the test can ask for, so the trace rules run without a file on disk.</summary>
internal sealed class FakeTraceFile : ITraceFile
{
/// <summary>The lines in the file, oldest first.</summary>
internal List<string> Lines { get; } = [];
/// <summary>How many times the file was deleted.</summary>
internal int Deleted { get; private set; }
/// <summary>The size the file reports; a test sets it to put the file above or below the limit.</summary>
public long Length { get; set; }
/// <summary>When set, appending fails with this.</summary>
internal Exception? AppendFailure { get; set; }
/// <summary>When set, reading fails with this, part way through the file as a disk error would.</summary>
internal Exception? ReadFailure { get; set; }
/// <summary>Empties the file and counts the deletion.</summary>
public void Delete()
{
Deleted++;
Lines.Clear();
Length = 0;
}
/// <summary>Adds the line, or fails as asked.</summary>
public void Append(string line)
{
if(AppendFailure is not null)
{ throw AppendFailure; }
Lines.Add(line);
}
/// <summary>Yields the lines, failing after the first one when a read failure was asked for.</summary>
public IEnumerable<string> ReadLines()
{
foreach(string line in Lines.ToArray())
{
yield return line;
if(ReadFailure is not null)
{ throw ReadFailure; }
}
}
}
@@ -8,17 +8,33 @@ namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// A clock that moves only when a test advances it. A wait made through it (<c>Task.Delay</c> with a time provider creates a
/// timer here) ends during <see cref="Advance"/> instead of after real time has passed, so a test can let exactly one
/// heartbeat interval elapse, and can first see that the code under test has actually started waiting.
/// heartbeat interval elapse, and can first see that the code under test has actually started waiting. Unless a test
/// says otherwise it starts at the real time and uses the machine's time zone; a test of code that writes or reads local
/// time gives it a fixed instant and a fixed zone, so the result does not depend on when or where the test runs.
/// </summary>
internal sealed class ManualTimeProvider : TimeProvider
internal sealed class ManualTimeProvider(DateTimeOffset? start = null, TimeZoneInfo? localTimeZone = null) : TimeProvider
{
/// <summary>Guards the manual time and the list of timers, which tests and the code under test touch from different threads.</summary>
private readonly Lock _lock = new();
/// <summary>Every timer created on this clock and not yet disposed.</summary>
private readonly List<ManualTimer> _timers = [];
private DateTimeOffset _now = DateTimeOffset.UtcNow;
/// <summary>The manual time; it starts at the real time of creation so values compared with the system clock stay plausible.</summary>
/// <summary>The manual time, in universal time.</summary>
private DateTimeOffset _now = (start ?? DateTimeOffset.UtcNow).ToUniversalTime();
/// <summary>The zone local time is given in: the one the test fixed, or the machine's when the test fixed none.</summary>
public override TimeZoneInfo LocalTimeZone => localTimeZone ?? base.LocalTimeZone;
/// <summary>The manual time; unless the test fixed a start it begins at the real time of creation, so values compared with the system clock stay plausible.</summary>
public override DateTimeOffset GetUtcNow()
{
lock(_lock)
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using System.Runtime.InteropServices;
using Microsoft.Win32;
using Microsoft.Win32.SafeHandles;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// A registry hive of the test's own: one file in a temporary folder the test creates, loaded with RegLoadAppKey so that
/// only this process sees it. It is no part of the machine hive or of any user's hive, needs no administrator rights,
/// and is unloaded and deleted with its folder when the test is done. Code that takes a base key can be run against it
/// exactly as against the real registry, while nothing on this computer's registry is read or changed.
/// </summary>
internal sealed class PrivateRegistryHive : IDisposable
{
/// <summary>The access asked for on the hive's root: everything a key allows.</summary>
private const int KeyAllAccess = 0xF003F;
/// <summary>The temporary folder that holds the hive file and the log files Windows keeps beside it.</summary>
private readonly string _folder = Path.Combine(Path.GetTempPath(), "qqq-private-hive-" + Guid.NewGuid().ToString("N"));
/// <summary>The root key of the private hive, in the 64-bit view like the record of the installed product.</summary>
internal RegistryKey Root { get; }
/// <summary>Creates the folder and loads a new, empty hive from a file in it.</summary>
internal PrivateRegistryHive()
{
Directory.CreateDirectory(_folder);
int result = RegLoadAppKeyW(Path.Combine(_folder, "test.hive"), out SafeRegistryHandle handle, KeyAllAccess, 0, 0);
if(result != 0)
{
Directory.Delete(_folder, recursive: true);
throw new Win32Exception(result, "A private registry hive could not be loaded from a file in the test's temporary folder (RegLoadAppKey).");
}
Root = RegistryKey.FromHandle(handle, RegistryView.Registry64);
}
/// <summary>Closes the root, which unloads the hive, and removes the folder with the hive file.</summary>
public void Dispose()
{
Root.Dispose();
Directory.Delete(_folder, recursive: true);
}
/// <summary>Loads a hive file as a private hive of the calling process and returns a handle to its root key.</summary>
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, ExactSpelling = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
private static extern int RegLoadAppKeyW(string file, out SafeRegistryHandle result, int samDesired, int options, int reserved);
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Qqq.PrintAgent.Core;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// What the installed product stages the sign-in task definition in. The factory is the only place the real folder and
/// the machine state guard's checks are chosen, so this pins that both checks are the guard's own and that the folder
/// is inside the one the guard protects. Nothing is created, read or checked on this computer: the factory only names
/// a place and two checks, and no check is called.
/// </summary>
public sealed class ProtectedStagingTests
{
/// <summary>
/// The definition is staged in the setup folder inside the machine state folder, which is the folder the guard
/// protects, and both checks are the guard's own: the folder check and the check of the staged file.
/// </summary>
[Fact]
public void InstalledProductStagesInTheMachineStateFolderWithTheGuardsChecks()
{
var staging = ProtectedStaging.MachineSetup();
Assert.Equal(Path.Combine(MachineStateDirectory.Root, "setup"), staging.Directory);
Assert.Equal<Action<string>>(MachineStateDirectory.EnsureForPath, staging.EnsureFolder);
Assert.Equal<Action<string>>(MachineStateDirectory.ValidateFile, staging.ValidateFile);
}
}
@@ -0,0 +1,230 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.Win32;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The real registry record, run against a private hive file in the test's temporary folder (see
/// <see cref="PrivateRegistryHive"/>), under the same key path the installed product uses. It pins the two value names
/// and kinds an installed product leaves in the registry, that a name written by a 1.2.x install is read as it is, what
/// a missing key or value means, and what happens to a value of a kind the record does not write. Only the last test
/// looks at the real registry, and it only opens the handle of the machine hive to see which one the factory binds.
/// </summary>
public sealed class RegistryShortcutRecordTests
{
/// <summary>The key path under the base key: the installed product's.</summary>
private const string Key = RegistryShortcutRecord.MachinePath;
/// <summary>
/// With no key, and with a key that holds neither value, nothing is recorded. Reading and forgetting create nothing:
/// the key is still missing after every read and clear, which is what lets an uninstall with nothing recorded leave
/// the registry untouched.
/// </summary>
[Fact]
public void NothingIsRecordedWhenTheKeyOrTheValuesAreMissing()
{
using var hive = new PrivateRegistryHive();
List<string> warnings = [];
var record = new RegistryShortcutRecord(hive.Root, Key, warnings.Add);
Assert.Null(record.ReadLastShortcutName());
Assert.Empty(record.ReadPendingShortcutNames());
record.ClearLastShortcutName();
record.ClearPendingShortcutNames();
Assert.Null(hive.Root.OpenSubKey("SOFTWARE"));
hive.Root.CreateSubKey(Key).Dispose();
Assert.Null(record.ReadLastShortcutName());
Assert.Empty(record.ReadPendingShortcutNames());
record.ClearLastShortcutName();
record.ClearPendingShortcutNames();
Assert.Empty(warnings);
}
/// <summary>Proving the record changeable creates the key when it is missing, writes no value, and can be repeated; a base key that does not allow writing is refused and nothing is created.</summary>
[Fact]
public void ProvingTheRecordChangeableCreatesTheKeyAndNothingElse()
{
using var hive = new PrivateRegistryHive();
var record = new RegistryShortcutRecord(hive.Root, Key, _ => { });
record.RequireChangeable();
record.RequireChangeable();
using(RegistryKey created = Assert.IsType<RegistryKey>(hive.Root.OpenSubKey(Key)))
{
Assert.Equal(0, created.ValueCount);
}
hive.Root.CreateSubKey("Closed").Dispose();
using RegistryKey closed = Assert.IsType<RegistryKey>(hive.Root.OpenSubKey("Closed", writable: false));
_ = Assert.Throws<UnauthorizedAccessException>(new RegistryShortcutRecord(closed, Key, _ => { }).RequireChangeable);
Assert.Equal(0, closed.SubKeyCount);
}
/// <summary>
/// Both notes are written under their exact names and kinds, a string and a multi-string, and read back as written,
/// in order. Writing again replaces what was there; forgetting one leaves the other.
/// </summary>
[Fact]
public void BothNotesAreWrittenUnderTheirExactNamesAndKindsAndReadBack()
{
using var hive = new PrivateRegistryHive();
List<string> warnings = [];
var record = new RegistryShortcutRecord(hive.Root, Key, warnings.Add);
record.WriteLastShortcutName("Acme Print Connector.lnk");
record.WritePendingShortcutNames(["Old Brand.lnk", "Größe & Söhne.lnk"]);
using(RegistryKey key = Assert.IsType<RegistryKey>(hive.Root.OpenSubKey(@"SOFTWARE\QRunIO\PrintAgent")))
{
Assert.Equal(new[] { "LastShortcutName", "PendingShortcutNames" }, key.GetValueNames().Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Equal(RegistryValueKind.String, key.GetValueKind("LastShortcutName"));
Assert.Equal(RegistryValueKind.MultiString, key.GetValueKind("PendingShortcutNames"));
Assert.Equal("Acme Print Connector.lnk", key.GetValue("LastShortcutName"));
Assert.Equal(new[] { "Old Brand.lnk", "Größe & Söhne.lnk" }, Assert.IsType<string[]>(key.GetValue("PendingShortcutNames")), StringComparer.Ordinal);
}
Assert.Equal("Acme Print Connector.lnk", record.ReadLastShortcutName());
Assert.Equal(new[] { "Old Brand.lnk", "Größe & Söhne.lnk" }, record.ReadPendingShortcutNames(), StringComparer.Ordinal);
record.WriteLastShortcutName("Globex Connector.lnk");
record.WritePendingShortcutNames(["Acme Print Connector.lnk"]);
Assert.Equal("Globex Connector.lnk", record.ReadLastShortcutName());
Assert.Equal(new[] { "Acme Print Connector.lnk" }, record.ReadPendingShortcutNames(), StringComparer.Ordinal);
record.ClearPendingShortcutNames();
Assert.Equal("Globex Connector.lnk", record.ReadLastShortcutName());
Assert.Empty(record.ReadPendingShortcutNames());
record.WritePendingShortcutNames(["Acme Print Connector.lnk"]);
record.ClearLastShortcutName();
Assert.Null(record.ReadLastShortcutName());
Assert.Equal(new[] { "Acme Print Connector.lnk" }, record.ReadPendingShortcutNames(), StringComparer.Ordinal);
Assert.Empty(warnings);
}
/// <summary>
/// The key as a 1.2.x install left it, a string value naming the shortcut beside the installer's own label values,
/// is read unchanged, with no other names. Recording other names afterwards leaves that value and the labels exactly
/// as they were, so a 1.2.x product restored by a failed upgrade still finds its shortcut.
/// </summary>
[Fact]
public void NameWrittenByAnEarlierVersionIsReadUnchangedAndLeftAsItIs()
{
using var hive = new PrivateRegistryHive();
using(RegistryKey earlier = hive.Root.CreateSubKey(Key))
{
earlier.SetValue("DisplayName", "Acme Print Connector", RegistryValueKind.String);
earlier.SetValue("CompanyName", "Acme", RegistryValueKind.String);
earlier.SetValue("LastShortcutName", "Acme Print Connector.lnk", RegistryValueKind.String);
}
List<string> warnings = [];
var record = new RegistryShortcutRecord(hive.Root, Key, warnings.Add);
Assert.Equal("Acme Print Connector.lnk", record.ReadLastShortcutName());
Assert.Empty(record.ReadPendingShortcutNames());
record.RequireChangeable();
record.WritePendingShortcutNames(["Acme Print Connector.lnk", "Globex Connector.lnk"]);
record.ClearPendingShortcutNames();
using RegistryKey key = Assert.IsType<RegistryKey>(hive.Root.OpenSubKey(Key));
Assert.Equal(new[] { "CompanyName", "DisplayName", "LastShortcutName" }, key.GetValueNames().Order(StringComparer.Ordinal), StringComparer.Ordinal);
Assert.Equal(RegistryValueKind.String, key.GetValueKind("LastShortcutName"));
Assert.Equal("Acme Print Connector.lnk", key.GetValue("LastShortcutName"));
Assert.Equal("Acme Print Connector", key.GetValue("DisplayName"));
Assert.Empty(warnings);
}
/// <summary>
/// A value of a kind the record does not write is never used: it reads as nothing recorded, and each such read gives
/// one warning that names the value, so it is never passed over silently. The value itself is left as it is until
/// the record is written, which replaces it with the right kind; after that it reads normally and nothing is warned.
/// </summary>
[Theory]
[InlineData("LastShortcutName", RegistryValueKind.DWord)]
[InlineData("LastShortcutName", RegistryValueKind.MultiString)]
[InlineData("LastShortcutName", RegistryValueKind.Binary)]
[InlineData("PendingShortcutNames", RegistryValueKind.String)]
[InlineData("PendingShortcutNames", RegistryValueKind.ExpandString)]
[InlineData("PendingShortcutNames", RegistryValueKind.QWord)]
public void ValueOfAKindTheRecordDoesNotWriteIsNothingRecordedAndIsWarnedAbout(string name, RegistryValueKind kind)
{
using var hive = new PrivateRegistryHive();
using(RegistryKey key = hive.Root.CreateSubKey(Key))
{
object value = kind switch
{
RegistryValueKind.DWord => 7,
RegistryValueKind.QWord => 7L,
RegistryValueKind.Binary => new byte[] { 1, 2, 3 },
RegistryValueKind.MultiString => new[] { "Acme Print Connector.lnk" },
_ => "Acme Print Connector.lnk",
};
key.SetValue(name, value, kind);
}
List<string> warnings = [];
var record = new RegistryShortcutRecord(hive.Root, Key, warnings.Add);
bool last = name is "LastShortcutName";
Assert.Null(record.ReadLastShortcutName());
Assert.Empty(record.ReadPendingShortcutNames());
Assert.Equal(new[]
{
last
? "The registry value LastShortcutName is not a string, so it was not used: no current shortcut is taken as recorded."
: "The registry value PendingShortcutNames is not a list of strings, so it was not used: no other shortcuts are taken as recorded.",
}, warnings, StringComparer.Ordinal);
using(RegistryKey untouched = Assert.IsType<RegistryKey>(hive.Root.OpenSubKey(Key)))
{
Assert.Equal(kind, untouched.GetValueKind(name));
}
record.WriteLastShortcutName("Globex Connector.lnk");
record.WritePendingShortcutNames(["Old Brand.lnk"]);
Assert.Equal("Globex Connector.lnk", record.ReadLastShortcutName());
Assert.Equal(new[] { "Old Brand.lnk" }, record.ReadPendingShortcutNames(), StringComparer.Ordinal);
_ = Assert.Single(warnings);
using RegistryKey replaced = Assert.IsType<RegistryKey>(hive.Root.OpenSubKey(Key));
Assert.Equal(last ? RegistryValueKind.String : RegistryValueKind.MultiString, replaced.GetValueKind(name));
}
/// <summary>
/// An expandable string under the current shortcut's name is a string and is read exactly as stored: the name of an
/// environment variable in it is not expanded, so what the record returns never depends on the environment of the
/// process that reads it. Nothing is warned.
/// </summary>
[Fact]
public void ExpandableStringIsReadAsStoredWithoutExpansion()
{
using var hive = new PrivateRegistryHive();
using(RegistryKey key = hive.Root.CreateSubKey(Key))
{
key.SetValue("LastShortcutName", "%SystemRoot% Connector.lnk", RegistryValueKind.ExpandString);
}
List<string> warnings = [];
Assert.Equal("%SystemRoot% Connector.lnk", new RegistryShortcutRecord(hive.Root, Key, warnings.Add).ReadLastShortcutName());
Assert.Empty(warnings);
}
/// <summary>
/// The record of the installed product is bound to this product's key in the machine hive, in the 64-bit view, with
/// the value names 1.x and the installer know. Only the handle of the machine hive is opened to see this; no key
/// under it is opened, read or written.
/// </summary>
[Fact]
public void InstalledProductRecordIsTheProductKeyOfTheMachineHiveInThe64BitView()
{
var record = RegistryShortcutRecord.Machine(_ => { });
using RegistryKey root = record.Root;
Assert.Equal("HKEY_LOCAL_MACHINE", root.Name);
Assert.Equal(RegistryView.Registry64, root.View);
Assert.Equal(@"SOFTWARE\QRunIO\PrintAgent", record.KeyPath);
Assert.Equal("LastShortcutName", RegistryShortcutRecord.LastName);
Assert.Equal("PendingShortcutNames", RegistryShortcutRecord.PendingNames);
}
}
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Security.AccessControl;
using System.Security.Principal;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The real folder security adapter, run in a temporary folder: a folder it creates has the descriptor it was given from
/// the moment it exists, and it reads back the descriptors Windows holds for a folder and for a file. The descriptor used
/// here names only the account the tests run as, so no administrator rights are needed and nothing outside the temporary
/// folder is touched.
/// </summary>
public sealed class WindowsPathSecurityTests
{
/// <summary>
/// A folder created with a protected descriptor that allows one account has exactly that access list when read back,
/// with nothing inherited from its parent, and a file then created inside it has that same rule by inheritance.
/// </summary>
[Fact]
public void FolderIsCreatedWithItsDescriptorAndDescriptorsAreReadBack()
{
string root = Path.Combine(Path.GetTempPath(), "qqq-path-security-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
using var identity = WindowsIdentity.GetCurrent();
SecurityIdentifier me = identity.User!;
DirectorySecurity descriptor = new();
descriptor.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
descriptor.AddAccessRule(new FileSystemAccessRule(me, FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
var security = new WindowsPathSecurity();
string folder = Path.Combine(root, "Protected");
security.CreateDirectory(folder, descriptor);
FileSystemSecurity ofFolder = security.ReadDirectorySecurity(folder);
Assert.True(ofFolder.AreAccessRulesProtected);
FileSystemAccessRule own = Assert.Single(Rules(ofFolder));
Assert.Equal((me.Value, FileSystemRights.FullControl, AccessControlType.Allow, false), (own.IdentityReference.Value, own.FileSystemRights, own.AccessControlType, own.IsInherited));
string file = Path.Combine(folder, "inside.txt");
File.WriteAllText(file, "synthetic");
FileSystemSecurity ofFile = security.ReadFileSecurity(file);
FileSystemAccessRule inherited = Assert.Single(Rules(ofFile));
Assert.Equal((me.Value, FileSystemRights.FullControl, AccessControlType.Allow, true), (inherited.IdentityReference.Value, inherited.FileSystemRights, inherited.AccessControlType, inherited.IsInherited));
Assert.NotNull(ofFile.GetOwner(typeof(SecurityIdentifier)));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// The identity it reports is the one of the test process: a well-formed security identifier that is the one of the
/// process token, and an administrator flag that agrees with the token read another way, by whether the
/// administrators group is among its enabled groups.
/// </summary>
[Fact]
public void IdentityIsTheOneOfTheRunningProcess()
{
var security = new WindowsPathSecurity();
using var identity = WindowsIdentity.GetCurrent();
Assert.Matches("^S-1-\\d+(-\\d+)+$", security.CurrentUserSid);
Assert.Equal(identity.User!.Value, security.CurrentUserSid);
Assert.Equal(identity.Groups!.Contains(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null)), security.IsAdministrator);
}
/// <summary>
/// An ordinary file and an ordinary folder are not links, and a junction is. The junction is made inside the
/// temporary folder with the command interpreter's own mklink, which needs no administrator rights, and points at a
/// folder beside it; it is removed again before the folder is.
/// </summary>
[Fact]
public void JunctionIsALinkAndAnOrdinaryFileOrFolderIsNot()
{
string root = Path.Combine(Path.GetTempPath(), "qqq-path-security-" + Guid.NewGuid().ToString("N"));
string target = Path.Combine(root, "target");
string junction = Path.Combine(root, "junction");
Directory.CreateDirectory(target);
try
{
string file = Path.Combine(target, "inside.txt");
File.WriteAllText(file, "synthetic");
using(Process mklink = Process.Start(new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "cmd.exe"), ["/d", "/c", "mklink", "/J", junction, target]) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true })!)
{
string said = mklink.StandardOutput.ReadToEnd() + mklink.StandardError.ReadToEnd();
mklink.WaitForExit();
Assert.True(mklink.ExitCode == 0, "The junction this test needs could not be made in its temporary folder: " + said);
}
var security = new WindowsPathSecurity();
Assert.True(security.IsLink(junction));
Assert.False(security.IsLink(target));
Assert.False(security.IsLink(file));
Directory.Delete(junction);
Assert.True(File.Exists(file));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
/// <summary>The access rules of a descriptor, explicit and inherited, by security identifier.</summary>
private static FileSystemAccessRule[] Rules(FileSystemSecurity security)
=> [.. security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier)).Cast<FileSystemAccessRule>()];
}
@@ -0,0 +1,174 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The real shortcut shell, run against Windows itself. It names five properties of the Windows shortcut object when it
/// writes and three when it reads, and a wrong name would fail every install, so these tests write real shortcut files
/// through it and read them back. They also pin what the Windows shortcut object hides: asked about a file it cannot
/// open, or one that is no shortcut, it answers with empty values, and the shell must not pass that off as a shortcut
/// that says something else. Every file is written inside a temporary folder the test creates and removes; nothing is
/// written to the Start menu.
/// </summary>
public sealed class WindowsShortcutShellTests
{
/// <summary>What a test says when the machine has no shortcut automation, instead of passing without having checked anything.</summary>
internal const string Unavailable = "The WScript.Shell automation object (Windows Script Host) is not available on this machine, so the shortcut shell cannot be checked against Windows here. Branding during an install would fail on this machine for the same reason. Run this test on a machine where Windows Script Host is enabled.";
/// <summary>The description that marks a shortcut as this application's.</summary>
private const string Ours = "QQQ Print Agent companion";
/// <summary>
/// A shortcut written through Windows exists as a file, reads back with the same program, arguments and description,
/// and holds the working folder and the icon it was given: both are found in the file itself, since they are not read
/// back through the shell. An empty argument list and a real one both survive.
/// </summary>
[Theory]
[InlineData("")]
[InlineData("--tray")]
public void ShortcutWrittenThroughWindowsReadsBackAndHoldsAllFiveValues(string arguments)
{
using var folder = new Folder();
string working = Path.Combine(folder.Root, "Working Folder");
Directory.CreateDirectory(working);
string icon = Path.Combine(folder.Root, "Synthetic Brand.ico");
string path = Path.Combine(folder.Root, "Acme Print Connector.lnk");
var shell = new WindowsShortcutShell();
shell.Write(path, new ShortcutLink(folder.Target, arguments, Ours, working, icon + ",0"));
Assert.True(File.Exists(path));
Assert.Equal(new ShortcutLink(folder.Target, arguments, Ours), shell.Read(path));
byte[] file = File.ReadAllBytes(path);
Assert.True(Holds(file, working), "The shortcut file does not hold the working folder it was given.");
Assert.True(Holds(file, icon), "The shortcut file does not hold the icon it was given.");
}
/// <summary>
/// A shortcut another program holds without sharing cannot be opened, and reading it fails as the file system fails.
/// It does not read as a shortcut with empty values, which is what the Windows shortcut object alone would answer and
/// what would make a shortcut of ours look like someone else's. A program that holds the file and shares reading is
/// no obstacle, and once the file is free it reads as before.
/// </summary>
[Fact]
public void ShortcutHeldByAnotherProgramWithoutSharingFailsToReadInsteadOfReadingAsEmpty()
{
using var folder = new Folder();
string path = Path.Combine(folder.Root, "Acme Print Connector.lnk");
var shell = new WindowsShortcutShell();
var written = new ShortcutLink(folder.Target, "", Ours);
shell.Write(path, written with { WorkingDirectory = folder.Root, IconLocation = folder.Target + ",0" });
using(new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None))
{
_ = Assert.Throws<IOException>(() => shell.Read(path));
}
using(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
{
Assert.Equal(written, shell.Read(path));
}
Assert.Equal(written, shell.Read(path));
}
/// <summary>
/// A file that opens but holds no shortcut reads as no shortcut at all, not as a shortcut that says something else:
/// an empty file, a file of other content, and a real shortcut cut off inside its header.
/// </summary>
[Theory]
[InlineData("empty")]
[InlineData("garbage")]
[InlineData("cut-off")]
public void FileThatHoldsNoShortcutReadsAsNoShortcut(string kind)
{
using var folder = new Folder();
string path = Path.Combine(folder.Root, "Acme Print Connector.lnk");
var shell = new WindowsShortcutShell();
if(kind is "empty")
{ File.WriteAllBytes(path, []); }
else if(kind is "garbage")
{ File.WriteAllText(path, new string('x', 4096)); }
else
{
shell.Write(path, new ShortcutLink(folder.Target, "", Ours, folder.Root, folder.Target + ",0"));
File.WriteAllBytes(path, File.ReadAllBytes(path)[..12]);
}
long length = new FileInfo(path).Length;
Assert.Null(shell.Read(path));
Assert.Equal(length, new FileInfo(path).Length);
}
/// <summary>Reading a path where nothing exists fails as the file system fails and creates no file; the rules look for the file first, so this is only met when it vanishes in between.</summary>
[Fact]
public void PathWhereNothingExistsFailsToRead()
{
using var folder = new Folder();
string path = Path.Combine(folder.Root, "Missing.lnk");
_ = Assert.Throws<FileNotFoundException>(() => new WindowsShortcutShell().Read(path));
Assert.False(File.Exists(path));
}
/// <summary>
/// A shortcut Windows cannot save, here because its folder does not exist, fails as a shell that could not do its
/// work: one kind of failure that names the step and carries what Windows said, and no file is left.
/// </summary>
[Fact]
public void ShortcutThatWindowsCannotSaveFailsAsAShellFailure()
{
using var folder = new Folder();
string path = Path.Combine(folder.Root, "No Such Folder", "Acme Print Connector.lnk");
InvalidOperationException failed = Assert.Throws<InvalidOperationException>(() => new WindowsShortcutShell().Write(path, new ShortcutLink(folder.Target, "", Ours, folder.Root, folder.Target + ",0")));
Assert.StartsWith("Windows shortcut automation failed (Save): ", failed.Message, StringComparison.Ordinal);
Assert.NotNull(failed.InnerException);
Assert.False(File.Exists(path));
}
/// <summary>Whether the bytes of a shortcut file contain the text, which Windows stores in shortcut files as UTF-16.</summary>
private static bool Holds(byte[] file, string text) => file.AsSpan().IndexOf(Encoding.Unicode.GetBytes(text)) >= 0;
/// <summary>A temporary folder with a synthetic program for shortcuts to point at; it fails the test clearly when the machine has no shortcut automation, and is removed afterwards.</summary>
private sealed class Folder : IDisposable
{
/// <summary>The temporary folder.</summary>
internal string Root { get; } = Path.Combine(Path.GetTempPath(), "qqq-shortcut-shell-" + Guid.NewGuid().ToString("N"));
/// <summary>An ordinary file that stands for the program a shortcut launches.</summary>
internal string Target { get; }
/// <summary>Checks for shortcut automation, then creates the folder and the synthetic program.</summary>
internal Folder()
{
Assert.True(Type.GetTypeFromProgID("WScript.Shell") is not null, Unavailable);
Directory.CreateDirectory(Root);
Target = Path.Combine(Root, "Synthetic Companion.exe");
File.WriteAllText(Target, "synthetic companion");
}
/// <summary>Removes the temporary folder.</summary>
public void Dispose() => Directory.Delete(Root, recursive: true);
}
}
@@ -0,0 +1,174 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Globalization;
using System.Security.Principal;
using Qqq.PrintAgent.Windows;
namespace Qqq.PrintAgent.Cli.Tests;
/// <summary>
/// The real task scheduler adapter, run without the scheduler. The program it starts is a parameter, so these tests give
/// it the repository's own child-process fixture, which prints, exits or waits as told and changes nothing; the other
/// members only read: a path's attributes in a temporary folder, the identifier of accounts every Windows has, and the
/// identity of the test process. Nothing here starts schtasks.exe or touches this computer's scheduled tasks.
/// </summary>
public sealed class WindowsTaskSchedulerTests
{
/// <summary>How long a child that is expected to finish may take; generous, because the test machine may be busy.</summary>
private static readonly TimeSpan Generous = TimeSpan.FromSeconds(60);
/// <summary>The fixture program built beside the tests.</summary>
private static string StandIn => Path.ChangeExtension(typeof(WorkerFixture.Program).Assembly.Location, ".exe");
/// <summary>A program that does not exist is reported as not started, which the rules turn into their own message, instead of failing with whatever Windows says.</summary>
[Fact]
public async Task ProgramThatCannotBeStartedIsReportedAsNotStarted()
{
string missing = Path.Combine(Path.GetTempPath(), "qqq-no-such-program-" + Guid.NewGuid().ToString("N") + ".exe");
Assert.Null(await new WindowsTaskScheduler().RunAsync(missing, ["/Query"], Generous));
}
/// <summary>A program that finishes is reported with its own exit code and exactly what it printed, and each argument reaches it as one argument, spaces and quotes included.</summary>
[Theory]
[InlineData(0, "printed by the stand-in")]
[InlineData(7, "two words, a \"quoted\" one and a trailing backslash \\")]
public async Task FinishedProgramIsReportedWithItsExitCodeAndOutput(int code, string text)
{
TaskSchedulerRun run = Assert.IsType<TaskSchedulerRun>(await new WindowsTaskScheduler().RunAsync(StandIn, ["--stand-in", "exit", code.ToString(CultureInfo.InvariantCulture), text, "0"], Generous));
Assert.Equal(new TaskSchedulerRun(code, text), run);
}
/// <summary>
/// A program that fills its error stream is not held up by it: the error stream is read while the program runs, so
/// the program gets to print its output and exit. A megabyte is far more than a pipe holds; if nothing read it, the
/// program would block before printing and this run would end at the limit instead.
/// </summary>
[Fact]
public async Task FullErrorStreamDoesNotHoldTheProgramUp()
{
TaskSchedulerRun run = Assert.IsType<TaskSchedulerRun>(await new WindowsTaskScheduler().RunAsync(StandIn, ["--stand-in", "exit", "3", "reached the end", "1048576"], Generous));
Assert.Equal(new TaskSchedulerRun(3, "reached the end"), run);
}
/// <summary>
/// A program that does not finish within the limit is killed and reported as timed out, with the cancellation that
/// ended the wait and no exit code or output of its own. The child leaves its process id in a file before it starts
/// waiting, and that process is gone afterwards.
/// </summary>
[Fact]
public async Task ProgramPastItsLimitIsKilledAndReportedAsTimedOut()
{
string root = Path.Combine(Path.GetTempPath(), "qqq-scheduler-run-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
string started = Path.Combine(root, "started.txt");
TaskSchedulerRun run = Assert.IsType<TaskSchedulerRun>(await new WindowsTaskScheduler().RunAsync(StandIn, ["--stand-in", "wait", started], TimeSpan.FromSeconds(6)));
_ = Assert.IsType<OperationCanceledException>(run.TimedOut, exactMatch: false);
Assert.Equal((0, ""), (run.ExitCode, run.Output));
Assert.True(File.Exists(started), "The stand-in did not start within the six second limit, so the kill could not be checked; the machine was too busy for this test.");
Assert.True(await HasExitedAsync(int.Parse(await File.ReadAllTextAsync(started), CultureInfo.InvariantCulture)), "The program that ran past its limit is still running.");
}
finally
{
Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// The caller is the test process itself: its program path, a well-formed security identifier that is the one of the
/// process token, and an administrator flag that agrees with the token read another way, by whether the
/// administrators group is among its enabled groups.
/// </summary>
[Fact]
public void CallerIsTheRunningProcessAndItsToken()
{
StartupCaller caller = new WindowsTaskScheduler().CurrentCaller();
using var identity = WindowsIdentity.GetCurrent();
Assert.Equal(Environment.ProcessPath, caller.ProcessPath);
Assert.Matches("^S-1-\\d+(-\\d+)+$", caller.UserSid);
Assert.Equal(identity.User!.Value, caller.UserSid);
Assert.Equal(identity.Groups!.Contains(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null)), caller.IsAdministrator);
}
/// <summary>A folder reports as a folder, a file as a file, neither as a link, and a path where nothing exists reports nothing.</summary>
[Fact]
public void AttributesAreReadForWhatExistsAndNothingIsReportedForWhatDoesNot()
{
string root = Path.Combine(Path.GetTempPath(), "qqq-scheduler-paths-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
try
{
string file = Path.Combine(root, "Qqq.PrintAgent.exe");
File.WriteAllText(file, "synthetic companion");
var scheduler = new WindowsTaskScheduler();
FileAttributes folderAttributes = Assert.NotNull(scheduler.AttributesOf(root));
FileAttributes fileAttributes = Assert.NotNull(scheduler.AttributesOf(file));
Assert.True(folderAttributes.HasFlag(FileAttributes.Directory));
Assert.False(fileAttributes.HasFlag(FileAttributes.Directory));
Assert.False((folderAttributes | fileAttributes).HasFlag(FileAttributes.ReparsePoint));
Assert.Null(scheduler.AttributesOf(Path.Combine(root, "missing.exe")));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// The name Windows itself gives a well-known account resolves back to that account's identifier, whatever language
/// Windows spells the name in, and a name no account on this computer has resolves to nothing instead of failing.
/// </summary>
[Theory]
[InlineData(WellKnownSidType.LocalSystemSid, "S-1-5-18")]
[InlineData(WellKnownSidType.BuiltinAdministratorsSid, "S-1-5-32-544")]
public void AccountNameResolvesToItsIdentifierAndAnUnknownNameToNothing(WellKnownSidType account, string identifier)
{
var scheduler = new WindowsTaskScheduler();
string name = new SecurityIdentifier(account, null).Translate(typeof(NTAccount)).Value;
Assert.Equal(identifier, scheduler.ResolveAccountSid(name));
Assert.Null(scheduler.ResolveAccountSid(Environment.MachineName + "\\qqq-none-" + Guid.NewGuid().ToString("N")[..8]));
}
/// <summary>Whether the process with the given id is gone, waiting a while for a process that was just killed to finish ending.</summary>
private static async Task<bool> HasExitedAsync(int processId)
{
try
{
using var process = Process.GetProcessById(processId);
using var patience = new CancellationTokenSource(Generous);
await process.WaitForExitAsync(patience.Token);
return true;
}
catch(ArgumentException)
{
return true;
}
catch(OperationCanceledException)
{
return false;
}
}
}
@@ -16,11 +16,16 @@ public static class Program
{
/// <summary>
/// Selects the fixture behavior from the arguments. With --launch-parent it starts a hanging child worker whose parent id is this
/// process, reports the child's id, and exits, so a test can check the child dies with its parent. Otherwise it runs the real
/// process, reports the child's id, and exits, so a test can check the child dies with its parent. With --stand-in first it is
/// the plain child program of <see cref="StandIn"/> and never touches the worker. Otherwise it runs the real
/// worker loop against a fake backend that can hang (--hang) or report a media change (--media-drift).
/// </summary>
public static async Task<int> Main(string[] args)
{
if(args.Length > 1 && string.Equals(args[0], StandIn.Flag, StringComparison.Ordinal))
{
return StandIn.Run(args);
}
if(args.Contains("--launch-parent", StringComparer.Ordinal))
{
ProcessStartInfo start = new(Environment.ProcessPath!) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardInput = true, RedirectStandardOutput = true };
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
namespace Qqq.PrintAgent.WorkerFixture;
/// <summary>
/// A harmless child program for tests of code that starts a program and waits for it. It fills its error stream, prints
/// what it is told and exits with the code it is told, or it waits until it is killed. It changes nothing on the
/// computer except one file the test names, in which it leaves its process id.
/// </summary>
internal static class StandIn
{
/// <summary>The first argument that selects this behavior of the fixture.</summary>
internal const string Flag = "--stand-in";
/// <summary>
/// Runs one of two behaviors. <c>--stand-in exit CODE TEXT COUNT</c> writes COUNT characters to the error stream,
/// then TEXT to the output stream, and exits with CODE; the error stream comes first, so a parent that does not read
/// it never sees TEXT. <c>--stand-in wait FILE</c> writes its process id to FILE and then never exits by itself.
/// </summary>
internal static int Run(string[] args)
{
if(string.Equals(args[1], "wait", StringComparison.Ordinal))
{
File.WriteAllText(args[2], Environment.ProcessId.ToString(CultureInfo.InvariantCulture));
Thread.Sleep(Timeout.Infinite);
}
Console.Error.Write(new string('e', int.Parse(args[4], CultureInfo.InvariantCulture)));
Console.Error.Flush();
Console.Out.Write(args[3]);
return int.Parse(args[2], CultureInfo.InvariantCulture);
}
}