diff --git a/TIASshot.Tests/IoUtilCombineSaveFilePathTests.cs b/TIASshot.Tests/IoUtilCombineSaveFilePathTests.cs
new file mode 100644
index 0000000..39f93e6
--- /dev/null
+++ b/TIASshot.Tests/IoUtilCombineSaveFilePathTests.cs
@@ -0,0 +1,85 @@
+using System;
+using System.IO;
+using NUnit.Framework;
+
+namespace TIASshot.Tests {
+ ///
+ /// IoUtil.CombineSaveFilePath のテスト.
+ /// 設定ファイル(config.xml の File/*)由来のファイル名が保存フォルダと結合されるとき,
+ /// 絶対パス指定・..\ トラバーサルでも保存先が保存フォルダの外へ出ないこと,および
+ /// 同梱 config.xml の既存値では結合結果が従来の Path.Combine と完全一致すること
+ /// (挙動不変の特性化)を検証する.
+ ///
+ [TestFixture]
+ public class IoUtilCombineSaveFilePathTests {
+
+ private readonly string _saveFolder = @"C:\TIAS_Data\2026-08-03\12_00_00-subject";
+
+ ///
+ /// 結合結果が保存フォルダ配下かどうかを判定する(.. は正規化して評価)
+ ///
+ private bool IsUnderSaveFolder(string path) {
+ var full = Path.GetFullPath(path);
+ var baseFull = Path.GetFullPath(_saveFolder)
+ .TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
+ return full.StartsWith(baseFull, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// ファイル名に絶対パスを指定しても保存先が保存フォルダの外に出ない
+ /// (Path.Combine は第2引数が絶対パスだと第1引数を破棄する)
+ ///
+ [Test]
+ public void CombineSaveFilePath_絶対パス指定_保存フォルダ配下に留まる() {
+ // Arrange: スタートアップ等の任意の場所への書き込みを試みる設定値
+ var filename = @"C:\Users\victim\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\run.csv";
+
+ // Act
+ var result = IoUtil.CombineSaveFilePath(_saveFolder, filename);
+
+ // Assert
+ Assert.IsTrue(IsUnderSaveFolder(result),
+ $"保存先が保存フォルダの外を指している: {Path.GetFullPath(result)}");
+ }
+
+ ///
+ /// ファイル名に ..\ トラバーサルを含めても保存先が保存フォルダの外に出ない
+ ///
+ [Test]
+ public void CombineSaveFilePath_トラバーサル指定_保存フォルダ配下に留まる() {
+ // Arrange
+ var filename = @"..\..\..\..\x.png";
+
+ // Act
+ var result = IoUtil.CombineSaveFilePath(_saveFolder, filename);
+
+ // Assert
+ Assert.IsTrue(IsUnderSaveFolder(result),
+ $"保存先が保存フォルダの外を指している: {Path.GetFullPath(result)}");
+ }
+
+ ///
+ /// 同梱 config.xml の File/* 既存値({CN}/{NO} 置換後の実値)では
+ /// 結合結果が従来の Path.Combine と完全一致する(挙動不変の特性化)
+ ///
+ [TestCase("Shot0001.png")] // File/RgbImage({NO} 置換後)
+ [TestCase("sRGB17_0001.jpg")] // File/SrgbImage({CN}/{NO} 置換後)
+ [TestCase("Info.csv")] // File/Info
+ [TestCase("TCC.png")] // File/TccImage
+ [TestCase("TCC_ROIs.jpg")] // File/TccRoisImage
+ [TestCase("TCC_RGB.csv")] // File/TccRgbValues
+ [TestCase("TCC_sRGB17.csv")] // File/TccSrgbValues({CN} 置換後)
+ [TestCase("Conv_RGB-sRGB17.csv")] // File/ConvRgb2Srgb({CN} 置換後)
+ [TestCase("Conv_RGB-XYZ17.csv")] // File/ConvRgb2Xyz({CN} 置換後)
+ [TestCase("Conv_sRGB-XYZ17.csv")] // File/ConvSrgb2Xyz({CN} 置換後)
+ [TestCase("Conv_RGB-XYZold.csv")] // File/ConvRgb2XyzOld
+ public void CombineSaveFilePath_同梱configの既存値_従来のPathCombineと一致する(string filename) {
+ // Act
+ var result = IoUtil.CombineSaveFilePath(_saveFolder, filename);
+
+ // Assert
+ Assert.AreEqual(Path.Combine(_saveFolder, filename), result,
+ "同梱 config.xml の既存値では結合結果が従来と完全一致すること");
+ }
+ }
+}
diff --git a/TIASshot.Tests/TIASshot.Tests.csproj b/TIASshot.Tests/TIASshot.Tests.csproj
index 6cf0344..e700351 100644
--- a/TIASshot.Tests/TIASshot.Tests.csproj
+++ b/TIASshot.Tests/TIASshot.Tests.csproj
@@ -60,6 +60,7 @@
+
diff --git a/TIASshot/Utils/IoUtil.cs b/TIASshot/Utils/IoUtil.cs
index 301f963..bf45a8b 100644
--- a/TIASshot/Utils/IoUtil.cs
+++ b/TIASshot/Utils/IoUtil.cs
@@ -98,6 +98,16 @@
}
///
+ /// 設定ファイル由来のファイル名を保存フォルダと結合し,保存先ファイルパスを返す.
+ ///
+ /// 保存フォルダのパス
+ /// 設定ファイル由来のファイル名({CN}/{NO} 置換後)
+ /// 保存先ファイルパス
+ public static string CombineSaveFilePath(string saveFolder, string filename) {
+ return Path.Combine(saveFolder, filename);
+ }
+
+ ///
/// 設定値をCSVファイルに書き込む
///
///