Skip to content

Commit 38711b9

Browse files
committed
Add CcuBackupVerifier for HomeMatic CCU backup validation and integrate with FirmwareBackupClient. Add tests for backup verification logic.
1 parent bc9896d commit 38711b9

11 files changed

Lines changed: 915 additions & 57 deletions

.serena/project.yml

Lines changed: 40 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,27 @@ project_name: "HomeMatic"
33

44

55
# list of languages for which language servers are started; choose from:
6-
# al bash clojure cpp csharp
7-
# csharp_omnisharp dart elixir elm erlang
8-
# fortran fsharp go groovy haskell
9-
# haxe java julia kotlin lua
10-
# markdown
11-
# matlab nix pascal perl php
12-
# php_phpactor powershell python python_jedi r
13-
# rego ruby ruby_solargraph rust scala
14-
# swift terraform toml typescript typescript_vts
15-
# vue yaml zig
6+
# al angular ansible bash clojure
7+
# cpp cpp_ccls crystal csharp csharp_omnisharp
8+
# dart elixir elm erlang fortran
9+
# fsharp go groovy haskell haxe
10+
# hlsl html java json julia
11+
# kotlin lean4 lua luau markdown
12+
# matlab msl nix ocaml pascal
13+
# perl php php_phpactor powershell python
14+
# python_jedi python_ty r rego ruby
15+
# ruby_solargraph rust scala scss solidity
16+
# svelte swift systemverilog terraform toml
17+
# typescript typescript_vts vue yaml zig
1618
# (This list may be outdated. For the current list, see values of Language enum here:
1719
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
1820
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
1921
# Note:
2022
# - For C, use cpp
2123
# - For JavaScript, use typescript
24+
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
25+
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
26+
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
2227
# - For Free Pascal/Lazarus, use pascal
2328
# Special requirements:
2429
# Some languages require additional setup/installations.
@@ -66,54 +71,17 @@ read_only: false
6671

6772
# list of tool names to exclude.
6873
# This extends the existing exclusions (e.g. from the global configuration)
69-
#
70-
# Below is the complete list of tools for convenience.
71-
# To make sure you have the latest list of tools, and to view their descriptions,
72-
# execute `uv run scripts/print_tool_overview.py`.
73-
#
74-
# * `activate_project`: Activates a project based on the project name or path.
75-
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
76-
# * `create_text_file`: Creates/overwrites a file in the project directory.
77-
# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
78-
# for example by saying that the information retrieved from a memory file is no longer correct
79-
# or no longer relevant for the project.
80-
# * `edit_memory`: Replaces content matching a regular expression in a memory.
81-
# * `execute_shell_command`: Executes a shell command.
82-
# * `find_file`: Finds files in the given relative paths
83-
# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
84-
# * `find_symbol`: Performs a global (or local) search using the language server backend.
85-
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
86-
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
87-
# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
88-
# for clients that do not read the initial instructions when the MCP server is connected.
89-
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
90-
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
91-
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
92-
# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
93-
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
94-
# * `read_file`: Reads a file within the project directory.
95-
# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
96-
# is relevant to the current task. You can infer whether the information
97-
# is relevant from the memory file name.
98-
# You should not read the same memory file multiple times in the same conversation.
99-
# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
100-
# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
101-
# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
102-
# For JB, we use a separate tool.
103-
# * `replace_content`: Replaces content in a file (optionally using regular expressions).
104-
# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
105-
# * `safe_delete_symbol`:
106-
# * `search_for_pattern`: Performs a search for a pattern in the project.
107-
# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
108-
# The memory name should be meaningful.
74+
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
10975
excluded_tools: []
11076

11177
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
11278
# This extends the existing inclusions (e.g. from the global configuration).
79+
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
11380
included_optional_tools: []
11481

11582
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
11683
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
84+
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
11785
fixed_tools: []
11886

11987
# list of mode names to that are always to be included in the set of active modes
@@ -124,11 +92,14 @@ fixed_tools: []
12492
# Set this to a list of mode names to always include the respective modes for this project.
12593
base_modes:
12694

127-
# list of mode names that are to be activated by default.
128-
# The full set of modes to be activated is base_modes + default_modes.
129-
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
95+
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
96+
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
97+
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
13098
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
99+
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
100+
# for this project.
131101
# This setting can, in turn, be overridden by CLI parameters (--mode).
102+
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
132103
default_modes:
133104

134105
# initial prompt for the project. It will always be given to the LLM upon activating the project
@@ -152,3 +123,19 @@ read_only_memory_patterns: []
152123
# Extends the list from the global configuration, merging the two lists.
153124
# Example: ["_archive/.*", "_episodes/.*"]
154125
ignored_memory_patterns: []
126+
127+
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
128+
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
129+
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
130+
added_modes:
131+
132+
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
133+
# Paths can be absolute or relative to the project root.
134+
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
135+
# symbols and references across package boundaries.
136+
# Currently supported for: TypeScript.
137+
# Example:
138+
# additional_workspace_folders:
139+
# - ../sibling-package
140+
# - ../shared-lib
141+
additional_workspace_folders: []

source/CreativeCoders.HomeMatic/CreativeCoders.HomeMatic.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
<PackageReference Include="Microsoft.Extensions.Http"/>
1010
</ItemGroup>
1111

12+
<ItemGroup>
13+
<InternalsVisibleTo Include="CreativeCoders.HomeMatic.Tests"/>
14+
<InternalsVisibleTo Include="DynamicProxyGenAssembly2"/>
15+
</ItemGroup>
16+
1217
<ItemGroup>
1318
<ProjectReference Include="..\CreativeCoders.HomeMatic.Core\CreativeCoders.HomeMatic.Core.csproj"/>
1419
<ProjectReference Include="..\CreativeCoders.HomeMatic.JsonRpc\CreativeCoders.HomeMatic.JsonRpc.csproj"/>

source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClient.cs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@ public sealed class FirmwareBackupClient : IFirmwareBackupClient
1212
{
1313
private readonly ICcuSessionClient _sessionClient;
1414
private readonly IFirmwareBackupDownloader _downloader;
15+
private readonly ICcuBackupVerifier _verifier;
1516
private readonly FirmwareBackupOptions _options;
1617
private readonly IFileSystem _fileSystem;
1718

1819
internal FirmwareBackupClient(
1920
ICcuSessionClient sessionClient,
2021
IFirmwareBackupDownloader downloader,
22+
ICcuBackupVerifier verifier,
2123
FirmwareBackupOptions options,
2224
IFileSystem fileSystem)
2325
{
2426
_sessionClient = Ensure.NotNull(sessionClient);
2527
_downloader = Ensure.NotNull(downloader);
28+
_verifier = Ensure.NotNull(verifier);
2629
_options = Ensure.NotNull(options);
2730
_fileSystem = Ensure.NotNull(fileSystem);
2831
}
@@ -38,11 +41,32 @@ public async Task<FirmwareBackupResult> CreateBackupAsync(CancellationToken canc
3841
{
3942
var download = await _downloader.DownloadAsync(sessionId, cancellationToken).ConfigureAwait(false);
4043

44+
var capacity = download.ContentLength is > 0 and <= int.MaxValue
45+
? (int)download.ContentLength.Value
46+
: 0;
47+
var content = new MemoryStream(capacity);
48+
49+
try
50+
{
51+
await using (var httpResources = download.HttpResources.ConfigureAwait(false))
52+
{
53+
await download.Content.CopyToAsync(content, cancellationToken).ConfigureAwait(false);
54+
}
55+
56+
content.Position = 0;
57+
await _verifier.VerifyAsync(content, cancellationToken).ConfigureAwait(false);
58+
content.Position = 0;
59+
}
60+
catch
61+
{
62+
await content.DisposeAsync().ConfigureAwait(false);
63+
throw;
64+
}
65+
4166
return new FirmwareBackupResult(
42-
download.Content,
67+
content,
4368
download.FileName,
4469
download.ContentLength,
45-
download.HttpResources,
4670
new LogoutDisposable(_sessionClient, sessionId));
4771
}
4872
catch

source/CreativeCoders.HomeMatic/FirmwareBackup/FirmwareBackupClientFactory.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ public IFirmwareBackupClient Create(FirmwareBackupOptions options)
5757
options.BackupCgiPath,
5858
options.BackupAction);
5959

60-
return new FirmwareBackupClient(sessionClient, downloader, options, _fileSystem);
60+
var verifier = new CcuBackupVerifier();
61+
62+
return new FirmwareBackupClient(sessionClient, downloader, verifier, options, _fileSystem);
6163
}
6264
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
using System.Formats.Tar;
2+
using System.IO.Compression;
3+
using CreativeCoders.Core;
4+
5+
namespace CreativeCoders.HomeMatic.FirmwareBackup.Internal;
6+
7+
/// <summary>
8+
/// Default <see cref="ICcuBackupVerifier"/>. Validates that a backup is a HomeMatic CCU backup archive
9+
/// (an uncompressed tar containing non-empty <c>signature</c> and <c>user_data.tar.gz</c> entries, the
10+
/// latter being a valid gzip archive).
11+
/// </summary>
12+
/// <remarks>
13+
/// The <c>signature</c> entry is only checked for presence and non-zero length; its bytes are not
14+
/// cryptographically verified, so a successful verification does not prove the backup's authenticity.
15+
/// </remarks>
16+
internal sealed class CcuBackupVerifier : ICcuBackupVerifier
17+
{
18+
private const string SignatureEntryName = "signature";
19+
20+
private const string UserDataEntryName = "user_data.tar.gz";
21+
22+
private const byte GzipId1 = 0x1F;
23+
24+
private const byte GzipId2 = 0x8B;
25+
26+
private const byte GzipDeflateMethod = 0x08;
27+
28+
/// <inheritdoc />
29+
public async Task VerifyAsync(Stream content, CancellationToken cancellationToken = default)
30+
{
31+
Ensure.NotNull(content);
32+
33+
if (content.CanSeek)
34+
{
35+
content.Seek(0, SeekOrigin.Begin);
36+
}
37+
38+
var signatureFound = false;
39+
var userDataValid = false;
40+
41+
try
42+
{
43+
await using var tarReader = new TarReader(content, leaveOpen: true);
44+
45+
while (await tarReader.GetNextEntryAsync(copyData: true, cancellationToken).ConfigureAwait(false)
46+
is { } entry)
47+
{
48+
var entryName = NormalizeEntryName(entry.Name);
49+
50+
switch (entryName)
51+
{
52+
case SignatureEntryName when entry.Length > 0:
53+
signatureFound = true;
54+
break;
55+
case UserDataEntryName when entry.Length > 0:
56+
userDataValid = await IsValidGzipArchiveAsync(entry, cancellationToken).ConfigureAwait(false);
57+
break;
58+
}
59+
}
60+
}
61+
catch (Exception ex) when (ex is not OperationCanceledException)
62+
{
63+
throw new InvalidFirmwareBackupException(
64+
"The downloaded backup is not a valid HomeMatic CCU backup: it could not be read as a tar archive.",
65+
ex);
66+
}
67+
68+
if (!signatureFound)
69+
{
70+
throw new InvalidFirmwareBackupException(
71+
$"The downloaded backup is not a valid HomeMatic CCU backup: the '{SignatureEntryName}' entry is missing or empty.");
72+
}
73+
74+
if (!userDataValid)
75+
{
76+
throw new InvalidFirmwareBackupException(
77+
$"The downloaded backup is not a valid HomeMatic CCU backup: the '{UserDataEntryName}' entry is missing, empty or is not a valid gzip archive.");
78+
}
79+
}
80+
81+
private static string NormalizeEntryName(string name)
82+
{
83+
var normalized = name.Replace('\\', '/');
84+
85+
var lastSeparator = normalized.LastIndexOf('/');
86+
87+
return lastSeparator < 0
88+
? normalized
89+
: normalized[(lastSeparator + 1)..];
90+
}
91+
92+
private static async Task<bool> IsValidGzipArchiveAsync(TarEntry entry, CancellationToken cancellationToken)
93+
{
94+
var dataStream = entry.DataStream;
95+
96+
if (dataStream is null)
97+
{
98+
return false;
99+
}
100+
101+
// The entry was read with copyData: true, so its data stream is a seekable in-memory copy.
102+
dataStream.Seek(0, SeekOrigin.Begin);
103+
104+
var header = new byte[3];
105+
106+
try
107+
{
108+
await dataStream.ReadExactlyAsync(header, cancellationToken).ConfigureAwait(false);
109+
}
110+
catch (EndOfStreamException)
111+
{
112+
return false;
113+
}
114+
115+
if (header[0] != GzipId1 || header[1] != GzipId2 || header[2] != GzipDeflateMethod)
116+
{
117+
return false;
118+
}
119+
120+
dataStream.Seek(0, SeekOrigin.Begin);
121+
122+
try
123+
{
124+
await using var gzip = new GZipStream(dataStream, CompressionMode.Decompress, leaveOpen: true);
125+
126+
await gzip.CopyToAsync(Stream.Null, cancellationToken).ConfigureAwait(false);
127+
}
128+
catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException)
129+
{
130+
return false;
131+
}
132+
133+
return true;
134+
}
135+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace CreativeCoders.HomeMatic.FirmwareBackup.Internal;
2+
3+
/// <summary>
4+
/// Verifies that a downloaded backup is a valid HomeMatic CCU backup archive.
5+
/// </summary>
6+
/// <remarks>
7+
/// This is a structural integrity check only: it confirms the archive contains the expected,
8+
/// non-empty entries. The <c>signature</c> entry is verified to be present and non-empty; its
9+
/// contents are not cryptographically validated, so a passing result is not proof of authenticity.
10+
/// </remarks>
11+
internal interface ICcuBackupVerifier
12+
{
13+
/// <summary>
14+
/// Verifies that the given stream contains a valid HomeMatic CCU backup.
15+
/// </summary>
16+
/// <param name="content">
17+
/// The stream containing the backup payload. If it is seekable, it is rewound to the beginning
18+
/// before reading; otherwise it is read from its current position. The stream is left open and
19+
/// its position is undefined after the call.
20+
/// </param>
21+
/// <param name="cancellationToken">Cancellation token.</param>
22+
/// <returns>A task that completes when verification succeeds.</returns>
23+
/// <exception cref="InvalidFirmwareBackupException">
24+
/// Thrown when the content is not a valid HomeMatic CCU backup.
25+
/// </exception>
26+
Task VerifyAsync(Stream content, CancellationToken cancellationToken = default);
27+
}

0 commit comments

Comments
 (0)