Compare commits

...

5 Commits

11 changed files with 740 additions and 402 deletions

View File

@@ -6,22 +6,9 @@ on:
- '**'
jobs:
# runTests:
# uses: PepperDash/workflow-templates/.github/workflows/essentialsplugins-tests.yml@main
# secrets: inherit
runTests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: 9.0.x
- name: Restore dependencies
working-directory: .
run: dotnet restore
- name: Test
working-directory: .
run: dotnet test --verbosity normal --no-restore --collect:"xplat code coverage"
uses: PepperDash/workflow-templates/.github/workflows/essentialsplugins-run-tests.yml@main
secrets: inherit
getVersion:
uses: PepperDash/workflow-templates/.github/workflows/essentialsplugins-getversion.yml@main
secrets: inherit
@@ -36,4 +23,5 @@ jobs:
version: ${{ needs.getVersion.outputs.version }}
tag: ${{ needs.getVersion.outputs.tag }}
channel: ${{ needs.getVersion.outputs.channel }}
bypassPackageCheck: true
bypassPackageCheck: true
devToolsVersion: ${{ vars.ESSENTIALSDEVTOOLSVERSION }}

View File

@@ -1,247 +0,0 @@
name: Essentials v3 Development Build
on:
push:
branches:
- feature-3.0.0/*
- hotfix-3.0.0/*
- release-3.0.0/*
- development-3.0.0
env:
SOLUTION_PATH: .
SOLUTION_FILE: PepperDash.Essentials
VERSION: 0.0.0-buildtype-buildnumber
BUILD_TYPE: Debug
RELEASE_BRANCH: main
jobs:
Build_Project_4-Series:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# Detect environment (Act vs GitHub)
- name: Detect environment
id: detect_env
run: |
if [ -n "$ACT" ]; then
echo "is_local=true" >> $GITHUB_OUTPUT
else
echo "is_local=false" >> $GITHUB_OUTPUT
fi
- name: Install prerequisites
run: |
if [ "${{ steps.detect_env.outputs.is_local }}" == "true" ]; then
# For Act - no sudo needed
apt-get update
apt-get install -y curl wget libicu-dev git unzip
else
# For GitHub runners - sudo required
sudo apt-get update
sudo apt-get install -y curl wget libicu-dev git unzip
fi
- name: Set Version Number
id: setVersion
shell: bash
run: |
latestVersion="3.0.0"
newVersion=$latestVersion
phase=""
newVersionString=""
if [[ $GITHUB_REF =~ ^refs/pull/.* ]]; then
phase="beta"
newVersionString="${newVersion}-${phase}-${GITHUB_RUN_NUMBER}"
elif [[ $GITHUB_REF =~ ^refs/heads/hotfix-3.0.0/.* ]]; then
phase="hotfix"
newVersionString="${newVersion}-${phase}-${GITHUB_RUN_NUMBER}"
elif [[ $GITHUB_REF =~ ^refs/heads/feature-3.0.0/.* ]]; then
phase="alpha"
newVersionString="${newVersion}-${phase}-${GITHUB_RUN_NUMBER}"
elif [[ $GITHUB_REF == "refs/heads/development-3.0.0" ]]; then
phase="beta"
newVersionString="${newVersion}-${phase}-${GITHUB_RUN_NUMBER}"
elif [[ $GITHUB_REF =~ ^refs/heads/release-3.0.0/.* ]]; then
version=$(echo $GITHUB_REF | awk -F '/' '{print $NF}' | sed 's/v//')
phase="rc"
newVersionString="${version}-${phase}-${GITHUB_RUN_NUMBER}"
else
# For local builds or unrecognized branches
newVersionString="${newVersion}-local"
fi
echo "version=$newVersionString" >> $GITHUB_OUTPUT
# Create Build Properties file
- name: Create Build Properties
run: |
cat > Directory.Build.props << EOF
<Project>
<PropertyGroup>
<Version>${{ steps.setVersion.outputs.version }}</Version>
<AssemblyVersion>${{ steps.setVersion.outputs.version }}</AssemblyVersion>
<FileVersion>${{ steps.setVersion.outputs.version }}</FileVersion>
<InformationalVersion>${{ steps.setVersion.outputs.version }}</InformationalVersion>
<PackageVersion>${{ steps.setVersion.outputs.version }}</PackageVersion>
<NuGetVersion>${{ steps.setVersion.outputs.version }}</NuGetVersion>
</PropertyGroup>
</Project>
EOF
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.x'
- name: Restore NuGet Packages
run: dotnet restore ${SOLUTION_FILE}.sln
- name: Build Solution
run: dotnet build ${SOLUTION_FILE}.sln --configuration ${BUILD_TYPE} --no-restore
# Copy the CPZ file to the output directory with version in the filename
- name: Copy and Rename CPZ Files
run: |
mkdir -p ./output/cpz
# Find the main CPZ file in the build output
if [ -f "./src/PepperDash.Essentials/bin/${BUILD_TYPE}/net8/PepperDashEssentials.cpz" ]; then
cp "./src/PepperDash.Essentials/bin/${BUILD_TYPE}/net8/PepperDashEssentials.cpz" "./output/cpz/PepperDashEssentials.${{ steps.setVersion.outputs.version }}.cpz"
echo "Main CPZ file copied and renamed successfully."
else
echo "Warning: Main CPZ file not found at expected location."
find ./src -name "*.cpz" | xargs -I {} cp {} ./output/cpz/
fi
- name: Pack Solution
run: dotnet pack ${SOLUTION_FILE}.sln --configuration ${BUILD_TYPE} --output ./output/nuget --no-build
# List build artifacts (runs in both environments)
- name: List Build Artifacts
run: |
echo "=== Build Artifacts ==="
echo "NuGet Packages:"
find ./output/nuget -type f | sort
echo ""
echo "CPZ/CPLZ Files:"
find ./output -name "*.cpz" -o -name "*.cplz" | sort
echo "======================="
# Enhanced package inspection for local runs
- name: Inspect NuGet Packages
if: steps.detect_env.outputs.is_local == 'true'
run: |
echo "=== NuGet Package Details ==="
for pkg in $(find ./output/nuget -name "*.nupkg"); do
echo "Package: $(basename "$pkg")"
echo "Size: $(du -h "$pkg" | cut -f1)"
# Extract and show package contents
echo "Contents:"
unzip -l "$pkg" | tail -n +4 | head -n -2
echo "--------------------------"
# Try to extract and show the nuspec file (contains metadata)
echo "Metadata:"
unzip -p "$pkg" "*.nuspec" 2>/dev/null | grep -E "(<id>|<version>|<description>|<authors>|<dependencies>)" || echo "Metadata extraction failed"
echo "--------------------------"
done
echo "==========================="
# Tag creation - GitHub version
- name: Create tag for non-rc builds (GitHub)
if: ${{ !contains(steps.setVersion.outputs.version, 'rc') && steps.detect_env.outputs.is_local == 'false' }}
run: |
git config --global user.name "GitHub Actions"
git config --global user.email "actions@github.com"
git tag ${{ steps.setVersion.outputs.version }}
git push --tags origin
# Tag creation - Act mock version
- name: Create tag for non-rc builds (Act Mock)
if: ${{ !contains(steps.setVersion.outputs.version, 'rc') && steps.detect_env.outputs.is_local == 'true' }}
run: |
echo "Would create git tag: ${{ steps.setVersion.outputs.version }}"
echo "Would push tag to: origin"
# Release creation - GitHub version
- name: Create Release (GitHub)
if: steps.detect_env.outputs.is_local == 'false'
id: create_release
uses: ncipollo/release-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: 'output/cpz/*,output/**/*.cplz'
generateReleaseNotes: true
prerelease: ${{contains('debug', env.BUILD_TYPE)}}
tag: ${{ steps.setVersion.outputs.version }}
# Release creation - Act mock version with enhanced output
- name: Create Release (Act Mock)
if: steps.detect_env.outputs.is_local == 'true'
run: |
echo "=== Mock Release Creation ==="
echo "Would create release with:"
echo "- Tag: ${{ steps.setVersion.outputs.version }}"
echo "- Prerelease: ${{contains('debug', env.BUILD_TYPE)}}"
echo "- Artifacts matching pattern: output/cpz/*,output/**/*.cplz"
echo ""
echo "Matching artifacts:"
find ./output/cpz -type f
find ./output -name "*.cplz"
# Detailed info about release artifacts
echo ""
echo "Artifact Details:"
for artifact in $(find ./output/cpz -type f; find ./output -name "*.cplz"); do
echo "File: $(basename "$artifact")"
echo "Size: $(du -h "$artifact" | cut -f1)"
echo "Created: $(stat -c %y "$artifact")"
echo "MD5: $(md5sum "$artifact" | cut -d' ' -f1)"
echo "--------------------------"
done
echo "============================"
# NuGet setup - GitHub version
- name: Setup NuGet (GitHub)
if: steps.detect_env.outputs.is_local == 'false'
run: |
dotnet nuget add source https://nuget.pkg.github.com/pepperdash/index.json -n github -u pepperdash -p ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text
# NuGet setup - Act mock version
- name: Setup NuGet (Act Mock)
if: steps.detect_env.outputs.is_local == 'true'
run: |
echo "=== Mock NuGet Setup ==="
echo "Would add GitHub NuGet source: https://nuget.pkg.github.com/pepperdash/index.json"
echo "======================="
# Publish to NuGet - GitHub version
- name: Publish to Nuget (GitHub)
if: steps.detect_env.outputs.is_local == 'false'
run: dotnet nuget push ./output/nuget/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
# Publish to NuGet - Act mock version
- name: Publish to Nuget (Act Mock)
if: steps.detect_env.outputs.is_local == 'true'
run: |
echo "=== Mock Publish to NuGet ==="
echo "Would publish the following packages to https://api.nuget.org/v3/index.json:"
find ./output/nuget -name "*.nupkg" | sort
echo "============================="
# Publish to GitHub NuGet - GitHub version
- name: Publish to Github Nuget (GitHub)
if: steps.detect_env.outputs.is_local == 'false'
run: dotnet nuget push ./output/nuget/*.nupkg --source github --api-key ${{ secrets.GITHUB_TOKEN }}
# Publish to GitHub NuGet - Act mock version
- name: Publish to Github Nuget (Act Mock)
if: steps.detect_env.outputs.is_local == 'true'
run: |
echo "=== Mock Publish to GitHub NuGet ==="
echo "Would publish the following packages to the GitHub NuGet registry:"
find ./output/nuget -name "*.nupkg" | sort
echo "=================================="

View File

@@ -26,9 +26,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Dotnet Setup
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.x

View File

@@ -42,6 +42,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Core.Tests", "sr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Essentials.Core.Tests", "src\PepperDash.Essentials.Core.Tests\PepperDash.Essentials.Core.Tests.csproj", "{F508E0BA-E885-424F-9D4C-359CF0011DEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PepperDash.Essentials.Tests", "src\PepperDash.Essentials.Tests\PepperDash.Essentials.Tests.csproj", "{841EE676-7784-4456-8F76-3697C6D432A6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug 4.7.2|Any CPU = Debug 4.7.2|Any CPU
@@ -181,6 +183,24 @@ Global
{F508E0BA-E885-424F-9D4C-359CF0011DEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F508E0BA-E885-424F-9D4C-359CF0011DEF}.Release|x64.ActiveCfg = Release|Any CPU
{F508E0BA-E885-424F-9D4C-359CF0011DEF}.Release|x86.ActiveCfg = Release|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|Any CPU.ActiveCfg = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|Any CPU.Build.0 = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x64.ActiveCfg = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x64.Build.0 = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x86.ActiveCfg = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug 4.7.2|x86.Build.0 = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x64.ActiveCfg = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x64.Build.0 = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x86.ActiveCfg = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Debug|x86.Build.0 = Debug|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Release|Any CPU.Build.0 = Release|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Release|x64.ActiveCfg = Release|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Release|x64.Build.0 = Release|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Release|x86.ActiveCfg = Release|Any CPU
{841EE676-7784-4456-8F76-3697C6D432A6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -194,6 +214,7 @@ Global
{E5336563-1194-501E-BC4A-79AD9283EF90} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{680BA287-E61F-4B8D-BD7A-84C2504F5F9C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{F508E0BA-E885-424F-9D4C-359CF0011DEF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{841EE676-7784-4456-8F76-3697C6D432A6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {6907A4BF-7201-47CF-AAB1-3597F3B8E1C3}

View File

@@ -0,0 +1,334 @@
using System.IO.Compression;
using FluentAssertions;
using Xunit;
namespace PepperDash.Essentials.Tests.ControlSystem;
/// <summary>
/// Tests for <see cref="AssetLoader.Load"/>.
/// <see cref="AssetLoader"/> is the <c>System.IO</c>-based implementation that backs
/// <see cref="AssetLoader.Load"/>. Tests run against a
/// temporary directory tree so no Crestron runtime is required.
/// Debug is initialised with fakes via TestInitializer.
/// </summary>
public sealed class LoadAssetsTests : IDisposable
{
// ---------------------------------------------------------------------------
// Fixture: each test gets an isolated temp directory tree
//
// _rootDir/
// appdir/ ← applicationDirectoryPath (where the loader scans)
// user/
// program1/ ← filePathPrefix (where assets land)
// ---------------------------------------------------------------------------
private readonly string _rootDir;
private readonly string _appDir;
private readonly string _filePathPrefix;
public LoadAssetsTests()
{
_rootDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
_appDir = Path.Combine(_rootDir, "appdir");
_filePathPrefix = Path.Combine(_rootDir, "user", "program1") + Path.DirectorySeparatorChar;
Directory.CreateDirectory(_appDir);
Directory.CreateDirectory(_filePathPrefix);
}
public void Dispose()
{
if (Directory.Exists(_rootDir))
Directory.Delete(_rootDir, recursive: true);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private static byte[] EmptyZip()
{
using var ms = new MemoryStream();
using (var _ = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { }
return ms.ToArray();
}
private static byte[] ZipWithFile(string entryName, string content = "test")
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(entryName);
using var sw = new StreamWriter(entry.Open());
sw.Write(content);
}
return ms.ToArray();
}
private static byte[] ZipWithDirectory(string directoryEntryName)
{
// Directory entries have a trailing slash and an empty Name
var normalised = directoryEntryName.TrimEnd('/') + '/';
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
archive.CreateEntry(normalised);
}
return ms.ToArray();
}
private static byte[] ZipWithTraversalEntry()
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry("../traversal.txt");
using var sw = new StreamWriter(entry.Open());
sw.Write("should not appear");
}
return ms.ToArray();
}
private void WriteToAppDir(string fileName, byte[] contents) =>
File.WriteAllBytes(Path.Combine(_appDir, fileName), contents);
private void WriteTextToAppDir(string fileName, string text) =>
File.WriteAllText(Path.Combine(_appDir, fileName), text);
// ---------------------------------------------------------------------------
// No-op cases — nothing in the application directory
// ---------------------------------------------------------------------------
[Fact]
public void LoadAssets_EmptyApplicationDirectory_DoesNotThrow()
{
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().NotThrow();
}
// ---------------------------------------------------------------------------
// assets*.zip
// ---------------------------------------------------------------------------
[Fact]
public void LoadAssets_MultipleAssetsZips_ThrowsException()
{
WriteToAppDir("assets1.zip", EmptyZip());
WriteToAppDir("assets2.zip", EmptyZip());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<Exception>().WithMessage("*Multiple assets zip files*");
}
[Fact]
public void LoadAssets_SingleAssetsZip_ExtractsFileToFilePathPrefix()
{
WriteToAppDir("assets.zip", ZipWithFile("config.json", "{}"));
AssetLoader.Load(_appDir, _filePathPrefix);
File.Exists(Path.Combine(_filePathPrefix, "config.json")).Should().BeTrue();
}
[Fact]
public void LoadAssets_SingleAssetsZip_FileContentsArePreserved()
{
const string expected = "{\"key\":\"value\"}";
WriteToAppDir("assets.zip", ZipWithFile("data.json", expected));
AssetLoader.Load(_appDir, _filePathPrefix);
File.ReadAllText(Path.Combine(_filePathPrefix, "data.json")).Should().Be(expected);
}
[Fact]
public void LoadAssets_SingleAssetsZip_ZipIsDeletedAfterExtraction()
{
var zipPath = Path.Combine(_appDir, "assets.zip");
WriteToAppDir("assets.zip", ZipWithFile("file.txt"));
AssetLoader.Load(_appDir, _filePathPrefix);
File.Exists(zipPath).Should().BeFalse("assets zip should be cleaned up after extraction");
}
[Fact]
public void LoadAssets_AssetsZipWithDirectoryEntry_CreatesDirectory()
{
WriteToAppDir("assets.zip", ZipWithDirectory("subdir/"));
AssetLoader.Load(_appDir, _filePathPrefix);
Directory.Exists(Path.Combine(_filePathPrefix, "subdir/")).Should().BeTrue();
}
[Fact]
public void LoadAssets_AssetsZipWithPathTraversal_ThrowsInvalidOperationException()
{
WriteToAppDir("assets.zip", ZipWithTraversalEntry());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*trying to extract outside of the target directory*");
}
// ---------------------------------------------------------------------------
// htmlassets*.zip
// ---------------------------------------------------------------------------
[Fact]
public void LoadAssets_MultipleHtmlAssetsZips_ThrowsException()
{
WriteToAppDir("htmlassets1.zip", EmptyZip());
WriteToAppDir("htmlassets2.zip", EmptyZip());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<Exception>().WithMessage("*Multiple htmlassets zip files*");
}
[Fact]
public void LoadAssets_SingleHtmlAssetsZip_ExtractsToHtmlDirectory()
{
// htmlDir = rootDir/html
WriteToAppDir("htmlassets.zip", ZipWithFile("index.html", "<html/>"));
AssetLoader.Load(_appDir, _filePathPrefix);
var expectedHtmlDir = Path.Combine(_rootDir, "html");
File.Exists(Path.Combine(expectedHtmlDir, "index.html")).Should().BeTrue();
}
[Fact]
public void LoadAssets_SingleHtmlAssetsZip_ZipIsDeletedAfterExtraction()
{
var zipPath = Path.Combine(_appDir, "htmlassets.zip");
WriteToAppDir("htmlassets.zip", ZipWithFile("page.html"));
AssetLoader.Load(_appDir, _filePathPrefix);
File.Exists(zipPath).Should().BeFalse();
}
[Fact]
public void LoadAssets_HtmlAssetsZipWithPathTraversal_ThrowsInvalidOperationException()
{
WriteToAppDir("htmlassets.zip", ZipWithTraversalEntry());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*trying to extract outside of the target directory*");
}
[Fact]
public void LoadAssets_HtmlAssetsZip_ShallowFilePathPrefixThrowsWhenRootCannotBeDetermined()
{
// A path that is exactly ONE level below the filesystem root has no grandparent,
// so rootDir (programDir.Parent.Parent) is null and the guard should throw.
// DirectoryInfo works with non-existent paths, so we don't create the directory.
var filesystemRoot = Path.GetPathRoot(Path.GetTempPath())!;
var shallowPrefix = Path.Combine(filesystemRoot, "pepperDashTestShallow") + Path.DirectorySeparatorChar;
WriteToAppDir("htmlassets.zip", ZipWithFile("page.html"));
var act = () => AssetLoader.Load(_appDir, shallowPrefix);
act.Should().Throw<Exception>().WithMessage("*Unable to determine root directory for html extraction*");
}
// ---------------------------------------------------------------------------
// essentials-devtools*.zip
// ---------------------------------------------------------------------------
[Fact]
public void LoadAssets_MultipleDevToolsZips_ThrowsException()
{
WriteToAppDir("essentials-devtools1.zip", EmptyZip());
WriteToAppDir("essentials-devtools2.zip", EmptyZip());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<Exception>().WithMessage("*Multiple essentials-devtools zip files*");
}
[Fact]
public void LoadAssets_SingleDevToolsZip_ExtractsToHtmlDebugDirectory()
{
// debugDir = rootDir/html/debug
WriteToAppDir("essentials-devtools.zip", ZipWithFile("app.js", "console.log('hi');"));
AssetLoader.Load(_appDir, _filePathPrefix);
var expectedDebugDir = Path.Combine(_rootDir, "html", "debug");
File.Exists(Path.Combine(expectedDebugDir, "app.js")).Should().BeTrue();
}
[Fact]
public void LoadAssets_SingleDevToolsZip_ZipIsDeletedAfterExtraction()
{
var zipPath = Path.Combine(_appDir, "essentials-devtools.zip");
WriteToAppDir("essentials-devtools.zip", ZipWithFile("tool.js"));
AssetLoader.Load(_appDir, _filePathPrefix);
File.Exists(zipPath).Should().BeFalse();
}
[Fact]
public void LoadAssets_DevToolsZipWithPathTraversal_ThrowsInvalidOperationException()
{
WriteToAppDir("essentials-devtools.zip", ZipWithTraversalEntry());
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<InvalidOperationException>()
.WithMessage("*trying to extract outside of the target directory*");
}
// ---------------------------------------------------------------------------
// *configurationFile*.json
// ---------------------------------------------------------------------------
[Fact]
public void LoadAssets_MultipleJsonConfigFiles_ThrowsException()
{
WriteTextToAppDir("abcconfigurationFile1.json", "{}");
WriteTextToAppDir("abcconfigurationFile2.json", "{}");
var act = () => AssetLoader.Load(_appDir, _filePathPrefix);
act.Should().Throw<Exception>().WithMessage("*Multiple configuration files found*");
}
[Fact]
public void LoadAssets_SingleJsonConfigFile_IsMovedToFilePathPrefix()
{
const string fileName = "myconfigurationFile.json";
WriteTextToAppDir(fileName, "{}");
AssetLoader.Load(_appDir, _filePathPrefix);
File.Exists(Path.Combine(_appDir, fileName)).Should().BeFalse("source file should be moved, not copied");
File.Exists(Path.Combine(_filePathPrefix, fileName)).Should().BeTrue("file should exist at the file path prefix");
}
[Fact]
public void LoadAssets_SingleJsonConfigFile_ExistingDestinationIsReplaced()
{
const string fileName = "myconfigurationFile.json";
WriteTextToAppDir(fileName, "new content");
// Pre-populate the destination with stale content
File.WriteAllText(Path.Combine(_filePathPrefix, fileName), "old content");
AssetLoader.Load(_appDir, _filePathPrefix);
File.ReadAllText(Path.Combine(_filePathPrefix, fileName)).Should().Be("new content");
}
[Fact]
public void LoadAssets_SingleJsonConfigFile_ContentIsPreserved()
{
const string content = "{\"devices\":[]}";
const string fileName = "myconfigurationFile.json";
WriteTextToAppDir(fileName, content);
AssetLoader.Load(_appDir, _filePathPrefix);
File.ReadAllText(Path.Combine(_filePathPrefix, fileName)).Should().Be(content);
}
}

View File

@@ -0,0 +1,61 @@
using PepperDash.Core.Abstractions;
namespace PepperDash.Essentials.Tests.Fakes;
internal class FakeCrestronEnvironment : ICrestronEnvironment
{
public DevicePlatform DevicePlatform { get; set; } = DevicePlatform.Appliance;
public RuntimeEnvironment RuntimeEnvironment { get; set; } = RuntimeEnvironment.SimplSharpPro;
public string NewLine { get; set; } = "\r\n";
public uint ApplicationNumber { get; set; } = 1;
public uint RoomId { get; set; } = 0;
public event EventHandler<ProgramStatusEventArgs>? ProgramStatusChanged
{
add { }
remove { }
}
public event EventHandler<PepperDashEthernetEventArgs>? EthernetEventReceived
{
add { }
remove { }
}
public string GetApplicationRootDirectory() => System.IO.Path.GetTempPath();
public bool IsHardwareRuntime => false;
}
internal class NoOpCrestronConsole : ICrestronConsole
{
public void PrintLine(string message) { }
public void Print(string message) { }
public void ConsoleCommandResponse(string message) { }
public void AddNewConsoleCommand(Action<string> _, string __, string ___, ConsoleAccessLevel ____) { }
}
internal class InMemoryCrestronDataStore : ICrestronDataStore
{
private readonly Dictionary<string, object> _store = new();
public void InitStore() { }
public bool TryGetLocalInt(string key, out int value)
{
if (_store.TryGetValue(key, out var raw) && raw is int i) { value = i; return true; }
value = 0;
return false;
}
public bool SetLocalInt(string key, int value) { _store[key] = value; return true; }
public bool SetLocalUint(string key, uint value) { _store[key] = (int)value; return true; }
public bool TryGetLocalBool(string key, out bool value)
{
if (_store.TryGetValue(key, out var raw) && raw is bool b) { value = b; return true; }
value = false;
return false;
}
public bool SetLocalBool(string key, bool value) { _store[key] = value; return true; }
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<!-- Provides access to ControlSystem.LoadAssets (internal) after InternalsVisibleTo is set -->
<ProjectReference Include="..\PepperDash.Essentials\PepperDash.Essentials.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,28 @@
using System.Runtime.CompilerServices;
using PepperDash.Core.Abstractions;
using PepperDash.Essentials.Tests.Fakes;
namespace PepperDash.Essentials.Tests;
/// <summary>
/// Runs once before any type in this assembly is accessed.
/// Registers fake Crestron service implementations so that the <c>Debug</c> static
/// constructor never tries to reach the real Crestron SDK.
/// </summary>
internal static class TestInitializer
{
[ModuleInitializer]
internal static void Initialize()
{
DebugServiceRegistration.Register(
new FakeCrestronEnvironment
{
DevicePlatform = DevicePlatform.Server,
RuntimeEnvironment = RuntimeEnvironment.Other,
},
new NoOpCrestronConsole(),
new InMemoryCrestronDataStore());
}
}

View File

@@ -0,0 +1,256 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using PepperDash.Core;
using Serilog.Events;
namespace PepperDash.Essentials;
/// <summary>
/// Handles extracting embedded asset bundles and moving configuration files from the
/// application directory to the program file-path prefix at startup.
/// Implemented using <c>System.IO</c> types so it can run (and be tested) outside
/// of a Crestron runtime environment.
/// </summary>
internal static class AssetLoader
{
/// <summary>
/// Scans <paramref name="applicationDirectoryPath"/> for well-known zip bundles and
/// JSON configuration files and deploys them to <paramref name="filePathPrefix"/>.
/// </summary>
/// <param name="applicationDirectoryPath">
/// The directory to scan (typically the Crestron application root).
/// </param>
/// <param name="filePathPrefix">
/// The program's runtime working directory (e.g. <c>/nvram/program1/</c>).
/// </param>
internal static void Load(string applicationDirectoryPath, string filePathPrefix)
{
var applicationDirectory = new DirectoryInfo(applicationDirectoryPath);
Debug.LogMessage(LogEventLevel.Information,
"Searching: {applicationDirectory:l} for embedded assets - {Destination}",
applicationDirectory.FullName, filePathPrefix);
ExtractAssetsZip(applicationDirectory, filePathPrefix);
ExtractHtmlAssetsZip(applicationDirectory, filePathPrefix);
ExtractDevToolsZip(applicationDirectory, filePathPrefix);
MoveConfigurationFile(applicationDirectory, filePathPrefix);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static void ExtractAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix)
{
var zipFiles = applicationDirectory.GetFiles("assets*.zip");
if (zipFiles.Length > 1)
throw new Exception("Multiple assets zip files found. Cannot continue.");
if (zipFiles.Length == 1)
{
var zipFile = zipFiles[0];
var assetsRoot = Path.GetFullPath(filePathPrefix);
if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
!assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
{
assetsRoot += Path.DirectorySeparatorChar;
}
Debug.LogMessage(LogEventLevel.Information,
"Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName);
using (var archive = ZipFile.OpenRead(zipFile.FullName))
{
foreach (var entry in archive.Entries)
{
var destinationPath = Path.Combine(filePathPrefix, entry.FullName);
var fullDest = Path.GetFullPath(destinationPath);
if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
if (Directory.Exists(destinationPath))
Directory.Delete(destinationPath, recursive: true);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
entry.ExtractToFile(destinationPath, overwrite: true);
Debug.LogMessage(LogEventLevel.Information,
"Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
}
}
}
foreach (var file in zipFiles)
File.Delete(file.FullName);
}
private static void ExtractHtmlAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix)
{
var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip");
if (htmlZipFiles.Length > 1)
throw new Exception(
"Multiple htmlassets zip files found in application directory. " +
"Please ensure only one htmlassets*.zip file is present and retry.");
if (htmlZipFiles.Length == 1)
{
var htmlZipFile = htmlZipFiles[0];
var programDir = new DirectoryInfo(
filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var userOrNvramDir = programDir.Parent;
var rootDir = userOrNvramDir?.Parent;
if (rootDir == null)
throw new Exception(
$"Unable to determine root directory for html extraction. Current path: {filePathPrefix}");
var htmlDir = Path.Combine(rootDir.FullName, "html");
var htmlRoot = Path.GetFullPath(htmlDir);
if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
!htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
{
htmlRoot += Path.DirectorySeparatorChar;
}
Debug.LogMessage(LogEventLevel.Information,
"Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName);
using (var archive = ZipFile.OpenRead(htmlZipFile.FullName))
{
foreach (var entry in archive.Entries)
{
var destinationPath = Path.Combine(htmlDir, entry.FullName);
var fullDest = Path.GetFullPath(destinationPath);
if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
if (File.Exists(destinationPath))
File.Delete(destinationPath);
var parentDir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(parentDir))
Directory.CreateDirectory(parentDir);
entry.ExtractToFile(destinationPath, overwrite: true);
Debug.LogMessage(LogEventLevel.Information,
"Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
}
}
}
foreach (var file in htmlZipFiles)
File.Delete(file.FullName);
}
private static void ExtractDevToolsZip(DirectoryInfo applicationDirectory, string filePathPrefix)
{
var devToolsZipFiles = applicationDirectory.GetFiles("essentials-devtools*.zip");
if (devToolsZipFiles.Length > 1)
throw new Exception(
"Multiple essentials-devtools zip files found in application directory. " +
"Please ensure only one essentials-devtools*.zip file is present and retry.");
if (devToolsZipFiles.Length == 1)
{
var devToolsZipFile = devToolsZipFiles[0];
var programDir = new DirectoryInfo(
filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var userOrNvramDir = programDir.Parent;
var rootDir = userOrNvramDir?.Parent;
if (rootDir == null)
throw new Exception(
$"Unable to determine root directory for debug html extraction. Current path: {filePathPrefix}");
var debugDir = Path.Combine(rootDir.FullName, "html", "debug");
var debugRoot = Path.GetFullPath(debugDir);
if (!debugRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
!debugRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
{
debugRoot += Path.DirectorySeparatorChar;
}
Debug.LogMessage(LogEventLevel.Information,
"Found essentials-devtools zip file: {zipFile:l}... Unzipping to {Destination}...",
devToolsZipFile.FullName, debugDir);
using (var archive = ZipFile.OpenRead(devToolsZipFile.FullName))
{
foreach (var entry in archive.Entries)
{
var destinationPath = Path.Combine(debugDir, entry.FullName);
var fullDest = Path.GetFullPath(destinationPath);
if (!fullDest.StartsWith(debugRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
if (File.Exists(destinationPath))
File.Delete(destinationPath);
var parentDir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(parentDir))
Directory.CreateDirectory(parentDir);
entry.ExtractToFile(destinationPath, overwrite: true);
Debug.LogMessage(LogEventLevel.Information,
"Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
}
}
}
foreach (var file in devToolsZipFiles)
File.Delete(file.FullName);
}
private static void MoveConfigurationFile(DirectoryInfo applicationDirectory, string filePathPrefix)
{
var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json");
if (jsonFiles.Length > 1)
{
Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}",
jsonFiles.Select(f => f.FullName).ToArray());
throw new Exception("Multiple configuration files found. Cannot continue.");
}
if (jsonFiles.Length == 1)
{
var jsonFile = jsonFiles[0];
var finalPath = Path.Combine(filePathPrefix, jsonFile.Name);
Debug.LogMessage(LogEventLevel.Information,
"Found configuration file: {jsonFile:l}... Moving to: {Destination}",
jsonFile.FullName, finalPath);
if (File.Exists(finalPath))
{
Debug.LogMessage(LogEventLevel.Information,
"Removing existing configuration file: {Destination}", finalPath);
File.Delete(finalPath);
}
jsonFile.MoveTo(finalPath);
}
}
}

View File

@@ -271,6 +271,8 @@ public class ControlSystem : CrestronControlSystem, ILoadConfig
_ = new Core.DeviceFactory();
LoadAssets(Global.ApplicationDirectoryPathPrefix, Global.FilePathPrefix);
Debug.LogMessage(LogEventLevel.Information, "Starting Essentials load from configuration");
var filesReady = SetupFilesystem();
@@ -518,142 +520,7 @@ public class ControlSystem : CrestronControlSystem, ILoadConfig
}
private static void LoadAssets()
{
var applicationDirectory = new DirectoryInfo(Global.ApplicationDirectoryPathPrefix);
Debug.LogMessage(LogEventLevel.Information, "Searching: {applicationDirectory:l} for embedded assets - {Destination}", applicationDirectory.FullName, Global.FilePathPrefix);
var zipFiles = applicationDirectory.GetFiles("assets*.zip");
if (zipFiles.Length > 1)
{
throw new Exception("Multiple assets zip files found. Cannot continue.");
}
if (zipFiles.Length == 1)
{
var zipFile = zipFiles[0];
var assetsRoot = System.IO.Path.GetFullPath(Global.FilePathPrefix);
if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
{
assetsRoot += Path.DirectorySeparatorChar;
}
Debug.LogMessage(LogEventLevel.Information, "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName);
using (var archive = ZipFile.OpenRead(zipFile.FullName))
{
foreach (var entry in archive.Entries)
{
var destinationPath = Path.Combine(Global.FilePathPrefix, entry.FullName);
var fullDest = System.IO.Path.GetFullPath(destinationPath);
if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
// If a directory exists where a file should go, delete it
if (Directory.Exists(destinationPath))
Directory.Delete(destinationPath, true);
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath));
entry.ExtractToFile(destinationPath, true);
Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
}
}
}
// cleaning up zip files
foreach (var file in zipFiles)
{
File.Delete(file.FullName);
}
var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip");
if (htmlZipFiles.Length > 1)
{
throw new Exception("Multiple htmlassets zip files found in application directory. Please ensure only one htmlassets*.zip file is present and retry.");
}
if (htmlZipFiles.Length == 1)
{
var htmlZipFile = htmlZipFiles[0];
var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
var userOrNvramDir = programDir.Parent;
var rootDir = userOrNvramDir?.Parent;
if (rootDir == null)
{
throw new Exception($"Unable to determine root directory for html extraction. Current path: {Global.FilePathPrefix}");
}
var htmlDir = Path.Combine(rootDir.FullName, "html");
var htmlRoot = System.IO.Path.GetFullPath(htmlDir);
if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
!htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
{
htmlRoot += Path.DirectorySeparatorChar;
}
Debug.LogMessage(LogEventLevel.Information, "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName);
using (var archive = ZipFile.OpenRead(htmlZipFile.FullName))
{
foreach (var entry in archive.Entries)
{
var destinationPath = Path.Combine(htmlDir, entry.FullName);
var fullDest = System.IO.Path.GetFullPath(destinationPath);
if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory.");
if (string.IsNullOrEmpty(entry.Name))
{
Directory.CreateDirectory(destinationPath);
continue;
}
// Only delete the file if it exists and is a file, not a directory
if (File.Exists(destinationPath))
File.Delete(destinationPath);
var parentDir = Path.GetDirectoryName(destinationPath);
if (!string.IsNullOrEmpty(parentDir))
Directory.CreateDirectory(parentDir);
entry.ExtractToFile(destinationPath, true);
Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath);
}
}
}
// cleaning up html zip files
foreach (var file in htmlZipFiles)
{
File.Delete(file.FullName);
}
var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json");
if (jsonFiles.Length > 1)
{
Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}", jsonFiles.Select(f => f.FullName).ToArray());
throw new Exception("Multiple configuration files found. Cannot continue.");
}
if (jsonFiles.Length == 1)
{
var jsonFile = jsonFiles[0];
var finalPath = Path.Combine(Global.FilePathPrefix, jsonFile.Name);
Debug.LogMessage(LogEventLevel.Information, "Found configuration file: {jsonFile:l}... Moving to: {Destination}", jsonFile.FullName, finalPath);
if (File.Exists(finalPath))
{
Debug.LogMessage(LogEventLevel.Information, "Removing existing configuration file: {Destination}", finalPath);
File.Delete(finalPath);
}
jsonFile.MoveTo(finalPath);
}
}
internal static void LoadAssets(string applicationDirectoryPath, string filePathPrefix) =>
AssetLoader.Load(applicationDirectoryPath, filePathPrefix);
}

View File

@@ -27,6 +27,11 @@
<DebugType>pdbonly</DebugType>
<DocumentationFile>bin\$(Configuration)\PepperDashEssentials.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>PepperDash.Essentials.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Crestron.SimplSharp.SDK.Program" Version="2.21.128" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />