Store policy 10.2.2 forbids dynamic code inclusion, and this was the only third-party binary the app ever downloaded and ran. It had already failed in production: the pin aged out of BtbN's 14-day retention and the download 404'd on the creator's first real recording attempt (2026-09-01). Bundling kills that failure class permanently and deletes the startup network dependency. - scripts/fetch-ffmpeg.sh populates a gitignored ./ffmpeg from a pinned month-end BtbN LGPL-shared artifact, SHA-256 verified before extraction, idempotent, FFMPEG_ZIP= override. 135 MB of DLLs in git would tax every commit on this 9p tree; the URL + hash are the audit trail LGPL §6 wants. - ytLive.csproj copies ffmpeg\** to output and publish (Always), and a GuardBundledFfmpeg target fails the BUILD if the folder is missing, so a fresh clone finds out before a take rather than during one. - FfmpegLocator loses the downloader, the zip extraction and the %APPDATA%\ytLlive\tools cache. Order is now bundle -> PATH, deliberately inverted: a PATH ffmpeg may be a different major version or a GPL build, and the bundle is what was pinned, license-vetted and tested. A missing bundle throws loudly; the old "found something on disk" path could satisfy a missing bundle with a stale copy, and silent drift is the worst failure mode for a dependency certification inspects. - ffplay.exe (18 MB) is not shipped; nothing calls it. - THIRD-PARTY-NOTICES.txt: "downloads" -> "bundled" throughout, and the stale build tag autobuild-2026-08-09-13-03 — the tag that 404'd — corrected to autobuild-2026-08-31-13-27 with the exact artifact name and archive hash. OpenH264 made precise: compiled INTO avcodec-63.dll, no separate binary. Checked whether other third-party binaries needed bundling: no. e_sqlite3.dll and WebView2Loader.dll already ship via NuGet into runtimes\win-x64\native\, OpenH264 is inside avcodec-63.dll, the rest are managed. Two gaps remain that bundling cannot fix, recorded in the map: the WebView2 Evergreen runtime is a machine prerequisite (TASK 48 item 2), and Velopack's updater downloads and executes, so its teardown is a compliance action. Tests: 16 locator facts, suite 379/379, 0 warnings. The integration test runs the bundled ffmpeg -version, which is the only assertion that proves the sibling libav*.dll resolved — presence checks pass with the DLLs missing and a shared build then fails to start. TheLocatorCarriesNoNetworkCapability keeps 10.2.2 from rotting back. Analysis: TASKS/research-store-certification.md §4.
247 lines
11 KiB
C#
247 lines
11 KiB
C#
using System;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using Xunit;
|
|
using ytLive.Services.Encoder;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// The FFmpeg locator (TASK 4 ship step 2, rewritten by TASK 48 item 1).
|
|
///
|
|
/// <para><b>What changed.</b> The locator used to pin a BtbN release URL, download the zip
|
|
/// on first run, extract it, and execute it. That is Windows Store policy 10.2.2 (dynamic
|
|
/// code inclusion — <c>TASKS/research-store-certification.md</c> §4) and it had already
|
|
/// failed in production: the pin aged out of BtbN's 14-day retention and the download
|
|
/// <b>404'd on the creator's first real recording attempt</b> (2026-09-01). FFmpeg is now
|
|
/// <b>bundled</b>, fetched at build time by <c>scripts/fetch-ffmpeg.sh</c> and copied into
|
|
/// <c>ffmpeg/</c> beside the executable. Nothing is downloaded at runtime, ever.</para>
|
|
///
|
|
/// <para><b>Why the order flipped.</b> PATH used to win ("the user's own install wins").
|
|
/// With a bundle in the package that is backwards: a PATH <c>ffmpeg</c> may be a different
|
|
/// major version or a GPL build, and the bundle is the one that was pinned, license-vetted
|
|
/// and tested. PATH remains the escape hatch for someone who deliberately wants their own.</para>
|
|
/// </summary>
|
|
public class FfmpegLocatorTests
|
|
{
|
|
private static string TempDir()
|
|
{
|
|
var dir = Path.Combine(Path.GetTempPath(), "ytllive-tests-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(dir);
|
|
return dir;
|
|
}
|
|
|
|
/// <summary>Repo root, found by walking up to a file only the repo has.</summary>
|
|
private static string RepoRoot()
|
|
{
|
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (dir != null)
|
|
{
|
|
if (File.Exists(Path.Combine(dir.FullName, "ytLive.csproj"))) return dir.FullName;
|
|
dir = dir.Parent;
|
|
}
|
|
throw new FileNotFoundException($"could not locate ytLive.csproj above {AppContext.BaseDirectory}");
|
|
}
|
|
|
|
private static string RepoFile(params string[] parts)
|
|
{
|
|
var candidate = Path.Combine(new[] { RepoRoot() }.Concat(parts).ToArray());
|
|
if (File.Exists(candidate)) return candidate;
|
|
throw new FileNotFoundException($"could not locate {Path.Combine(parts)} under {RepoRoot()}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Good Dog integration test for this unit: prove the whole chain end to end —
|
|
/// the build actually copied the bundle into the app's output, the locator resolves it
|
|
/// without any network, and the located binary <b>executes</b>. Running it is the part
|
|
/// that matters most: FFmpeg is an lgpl-<i>shared</i> build, so it only starts if every
|
|
/// <c>libav*.dll</c> sits beside the exe. A packaging change that dropped the DLLs would
|
|
/// pass every existence check and still hand the user a broken encoder.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Locate_Integration_TheBuiltOutputShipsABundleThatRuns()
|
|
{
|
|
// NOTE: the output root is derived from the *project* directory, not from the
|
|
// repository folder name. The repo directory happens to also be called "ytLive",
|
|
// so naively appending "ytLive" would resolve to a path that only works on this
|
|
// machine's checkout.
|
|
var projectDir = Path.GetDirectoryName(RepoFile("ytLive.csproj"))!;
|
|
var binRoot = Path.Combine(projectDir, "bin");
|
|
Assert.True(Directory.Exists(binRoot),
|
|
$"no build output at {binRoot} — build ytLive.csproj before running this test");
|
|
|
|
var withBundle = Directory
|
|
.GetDirectories(binRoot, "*", SearchOption.AllDirectories)
|
|
.Where(d => File.Exists(Path.Combine(d, FfmpegLocator.BundleFolderName, FfmpegLocator.FileName)))
|
|
.ToArray();
|
|
|
|
Assert.True(withBundle.Length > 0,
|
|
$"No build output under {binRoot} carries ffmpeg/{FfmpegLocator.FileName}. " +
|
|
"Run ./scripts/fetch-ffmpeg.sh, then rebuild — the csproj GuardBundledFfmpeg " +
|
|
"target should have failed the build if the source folder was missing.");
|
|
|
|
var outputDir = withBundle[0];
|
|
var bundleDir = Path.Combine(outputDir, FfmpegLocator.BundleFolderName);
|
|
|
|
// The sibling DLLs are the shared-build's whole dependency story.
|
|
foreach (var required in new[] { FfmpegLocator.ProbeFileName, "avcodec-63.dll", "avformat-63.dll" })
|
|
Assert.True(File.Exists(Path.Combine(bundleDir, required)),
|
|
$"{required} must ship beside {FfmpegLocator.FileName} — a shared build resolves DLLs " +
|
|
"from the executable's own directory.");
|
|
|
|
// The locator resolves it with an empty PATH, i.e. nothing outside the bundle.
|
|
var resolved = await new FfmpegLocator(searchDirs: [], bundleRoot: outputDir).LocateAsync();
|
|
|
|
Assert.Equal(Path.Combine(bundleDir, FfmpegLocator.FileName), resolved);
|
|
|
|
// And it actually runs. This is what proves the DLLs resolve.
|
|
var info = new ProcessStartInfo
|
|
{
|
|
FileName = resolved,
|
|
Arguments = "-version",
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true,
|
|
};
|
|
using var process = Process.Start(info)!;
|
|
var stdout = process.StandardOutput.ReadToEnd();
|
|
Assert.True(process.WaitForExit(milliseconds: 30_000), "bundled ffmpeg -version did not exit in 30s");
|
|
Assert.Equal(0, process.ExitCode);
|
|
Assert.Contains("ffmpeg version", stdout, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Locate_PrefersTheBundle_OverAPathInstall()
|
|
{
|
|
var root = TempDir();
|
|
try
|
|
{
|
|
var pathDir = Path.Combine(root, "on-path");
|
|
var bundleDir = Path.Combine(root, FfmpegLocator.BundleFolderName);
|
|
Directory.CreateDirectory(pathDir);
|
|
Directory.CreateDirectory(bundleDir);
|
|
File.WriteAllText(Path.Combine(pathDir, FfmpegLocator.FileName), "user's ffmpeg");
|
|
var bundled = Path.Combine(bundleDir, FfmpegLocator.FileName);
|
|
File.WriteAllText(bundled, "bundled ffmpeg");
|
|
|
|
var resolved = await new FfmpegLocator([pathDir], root).LocateAsync();
|
|
Assert.Equal(bundled, resolved);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Locate_FallsBackToPath_WhenNoBundleIsPresent()
|
|
{
|
|
var root = TempDir();
|
|
try
|
|
{
|
|
var pathDir = Path.Combine(root, "on-path");
|
|
Directory.CreateDirectory(pathDir);
|
|
var onPath = Path.Combine(pathDir, FfmpegLocator.FileName);
|
|
File.WriteAllText(onPath, "user's ffmpeg");
|
|
|
|
var resolved = await new FfmpegLocator([pathDir], root).LocateAsync();
|
|
Assert.Equal(onPath, resolved);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Locate_ZeroByteBundle_IsNotUsable()
|
|
{
|
|
var root = TempDir();
|
|
try
|
|
{
|
|
var bundleDir = Path.Combine(root, FfmpegLocator.BundleFolderName);
|
|
Directory.CreateDirectory(bundleDir);
|
|
File.WriteAllText(Path.Combine(bundleDir, FfmpegLocator.FileName), "");
|
|
|
|
var ex = await Assert.ThrowsAsync<IOException>(
|
|
() => new FfmpegLocator([], root).LocateAsync());
|
|
Assert.Contains(FfmpegLocator.BundleFolderName, ex.Message, StringComparison.Ordinal);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A missing bundle must be LOUD and must say where to look. The old ladder had a
|
|
/// "found something on disk" path that could quietly satisfy a missing bundle with a
|
|
/// stale cached copy — silent drift in exactly the dependency Store certification
|
|
/// inspects, and silent drift is how a packaging bug ships.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Locate_NoBundleAndNoPath_ThrowsNamingTheBundleFolderAndTheFix()
|
|
{
|
|
var root = TempDir();
|
|
try
|
|
{
|
|
var ex = await Assert.ThrowsAsync<IOException>(
|
|
() => new FfmpegLocator([], root).LocateAsync());
|
|
Assert.Contains(FfmpegLocator.BundleFolderName, ex.Message, StringComparison.Ordinal);
|
|
Assert.Contains("fetch-ffmpeg.sh", ex.Message, StringComparison.Ordinal);
|
|
Assert.Contains("PATH", ex.Message, StringComparison.Ordinal);
|
|
}
|
|
finally
|
|
{
|
|
Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Store-gate assertion, kept as a source-level fact so it cannot rot: the locator
|
|
/// must contain no network capability at all. Policy 10.2.2 forbids download-and-execute,
|
|
/// and this is the only type in the app that ever executed a downloaded binary. If a
|
|
/// future change reintroduces an HttpClient, a URL, or a downloader here, this fails —
|
|
/// which is the entire point.
|
|
///
|
|
/// <para>Patterns are <b>call-shaped</b> (<c>new HttpClient</c>, not <c>HttpClient</c>)
|
|
/// on purpose. A bare word match flags the file's own doc comment, which legitimately
|
|
/// says "nothing is downloaded" — a test that fails on the prose explaining the
|
|
/// guarantee is a test that gets deleted instead of obeyed.</para>
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("new HttpClient")]
|
|
[InlineData("HttpClient()")]
|
|
[InlineData("GetByteArrayAsync(")]
|
|
[InlineData("GetStreamAsync(")]
|
|
[InlineData("WebRequest.Create")]
|
|
[InlineData("DownloadFileTaskAsync")]
|
|
[InlineData("http://")]
|
|
[InlineData("https://")]
|
|
[InlineData("PinnedUrl =")]
|
|
[InlineData("ExtractBinaries(")]
|
|
public void TheLocatorCarriesNoNetworkCapability(string forbidden)
|
|
{
|
|
var source = File.ReadAllText(
|
|
RepoFile("Services", "Encoder", "FfmpegLocator.cs"));
|
|
|
|
Assert.DoesNotContain(forbidden, source, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The guard that makes a forgotten bundle a build failure rather than a runtime one.
|
|
/// Without it, a fresh clone with no <c>ffmpeg/</c> builds cleanly and the app only
|
|
/// discovers the problem when the creator presses Record.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TheBuildRefusesToCompileWithoutTheBundle()
|
|
{
|
|
var csproj = File.ReadAllText(RepoFile("ytLive.csproj"));
|
|
Assert.Contains("GuardBundledFfmpeg", csproj, StringComparison.Ordinal);
|
|
Assert.Contains("ffmpeg\\ffmpeg.exe", csproj, StringComparison.Ordinal);
|
|
Assert.Contains("CopyToPublishDirectory=\"Always\"", csproj, StringComparison.Ordinal);
|
|
}
|
|
}
|