From 7ca59543bd985c26fc25005ce06837ac6d9bb0ab Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Wed, 1 Apr 2026 15:47:08 -0400 Subject: [PATCH 1/4] fix: add custom YAML tags support and improve YAML loading functionality --- .../include/ComposeManager.php | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index 11d3c9b..5e9aaec 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -2279,6 +2279,65 @@ function cancelDesc(myID) { $("#" + myID).tooltipster("close"); } + var composeYamlSchemaCache = null; + + function buildComposeYamlSchema() { + if (!jsyaml || typeof jsyaml.Type !== 'function' || !jsyaml.DEFAULT_SCHEMA || typeof jsyaml.DEFAULT_SCHEMA.extend !== 'function') { + return null; + } + + var customTags = ['!override', '!reset', '!merge']; + var kinds = ['scalar', 'sequence', 'mapping']; + var types = []; + + customTags.forEach(function(tag) { + kinds.forEach(function(kind) { + types.push(new jsyaml.Type(tag, { + kind: kind, + resolve: function() { + return true; + }, + construct: function(data) { + if (data === null || data === undefined) { + if (kind === 'sequence') return []; + if (kind === 'mapping') return {}; + return ''; + } + return data; + } + })); + }); + }); + + return jsyaml.DEFAULT_SCHEMA.extend(types); + } + + function stripUnsupportedYamlTags(content) { + if (!content) return content; + return content.replace(/!<[^>\n]+>|![A-Za-z_][A-Za-z0-9_.-]*/g, ''); + } + + function loadComposeYaml(content) { + var input = content || ''; + + if (!composeYamlSchemaCache) { + composeYamlSchemaCache = buildComposeYamlSchema(); + } + + try { + if (composeYamlSchemaCache) { + return jsyaml.load(input, { + schema: composeYamlSchemaCache + }); + } + return jsyaml.load(input); + } catch (e) { + if (e && /unknown tag/i.test(String(e.message || ''))) { + return jsyaml.load(stripUnsupportedYamlTags(input)); + } + throw e; + } + } function applyDesc(myID) { var newDesc = $("#newDesc" + myID).val(); var project = $("#" + myID).attr("data-scriptname"); @@ -2315,7 +2374,7 @@ function generateProfiles(myID, myProject = null) { var rawComposefile = JSON.parse(rawComposefile); if ((rawComposefile.result == 'success')) { - var main_doc = jsyaml.load(rawComposefile.content); + var main_doc = loadComposeYaml(rawComposefile.content); for (var service_key in main_doc.services) { var service = main_doc.services[service_key]; @@ -4085,10 +4144,10 @@ function loadLabelsData() { throw new Error('Failed to load compose file'); } - var mainDoc = jsyaml.load(composeData.content) || { + var mainDoc = loadComposeYaml(composeData.content) || { services: {} }; - var overrideDoc = jsyaml.load(overrideData.content || '') || { + var overrideDoc = loadComposeYaml(overrideData.content || '') || { services: {} }; @@ -4279,7 +4338,7 @@ function validateYaml(type, content) { try { if (content.trim()) { - jsyaml.load(content); + loadComposeYaml(content); } updateValidation(type, content, true); } catch (e) { From d9e48cf636a613567fd6b06ad2cf378844940cee Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 9 Apr 2026 13:20:12 -0400 Subject: [PATCH 2/4] fix(yaml): enhance support for custom YAML tags and add corresponding unit tests --- .../compose.manager/include/ComposeManager.php | 16 ++++++++++++---- tests/unit/ComposeManagerMainSourceTest.php | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index 5e9aaec..f8c30f3 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -2312,9 +2312,16 @@ function buildComposeYamlSchema() { return jsyaml.DEFAULT_SCHEMA.extend(types); } - function stripUnsupportedYamlTags(content) { + function hasComposeCustomYamlTag(content) { + if (!content) return false; + return /!(?:override|reset|merge)\b/.test(content) || /!<(?:override|reset|merge)>/.test(content); + } + + function stripComposeCustomYamlTags(content) { if (!content) return content; - return content.replace(/!<[^>\n]+>|![A-Za-z_][A-Za-z0-9_.-]*/g, ''); + return content + .replace(/!<(?:override|reset|merge)>/g, '') + .replace(/!(?:override|reset|merge)\b/g, ''); } function loadComposeYaml(content) { @@ -2332,8 +2339,9 @@ function loadComposeYaml(content) { } return jsyaml.load(input); } catch (e) { - if (e && /unknown tag/i.test(String(e.message || ''))) { - return jsyaml.load(stripUnsupportedYamlTags(input)); + var message = String((e && e.message) || ''); + if (/unknown tag/i.test(message) && hasComposeCustomYamlTag(input)) { + return jsyaml.load(stripComposeCustomYamlTags(input)); } throw e; } diff --git a/tests/unit/ComposeManagerMainSourceTest.php b/tests/unit/ComposeManagerMainSourceTest.php index e4dacec..6404dd3 100644 --- a/tests/unit/ComposeManagerMainSourceTest.php +++ b/tests/unit/ComposeManagerMainSourceTest.php @@ -66,4 +66,20 @@ public function testDockerLoadMapStoresParsedLimitBytes(): void $this->assertStringContainsString('var memPair = parseMemUsagePair(parts[2]);', $source); $this->assertStringContainsString('memLimitBytes: memPair.limit,', $source); } + + public function testComposeCustomTagSchemaSupportIsDeclared(): void + { + $source = $this->getPageSource(); + $this->assertStringContainsString("var customTags = ['!override', '!reset', '!merge'];", $source); + $this->assertStringContainsString('function hasComposeCustomYamlTag(content)', $source); + } + + public function testUnknownTagFallbackIsScopedToComposeTags(): void + { + $source = $this->getPageSource(); + $this->assertStringContainsString('function stripComposeCustomYamlTags(content)', $source); + $this->assertStringContainsString('.replace(/!<(?:override|reset|merge)>/g, \'\')', $source); + $this->assertStringContainsString('.replace(/!(?:override|reset|merge)\\b/g, \'\')', $source); + $this->assertStringContainsString('if (/unknown tag/i.test(message) && hasComposeCustomYamlTag(input)) {', $source); + } } From a10aeb7deda5709f8d0fb0357fd3ec7d9ba602c3 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Thu, 9 Apr 2026 21:36:36 -0400 Subject: [PATCH 3/4] fix(yaml): remove fallback from custom tags and surface errors to user instead --- .../include/ComposeManager.php | 30 ++++--------------- tests/unit/ComposeManagerMainSourceTest.php | 10 +------ 2 files changed, 6 insertions(+), 34 deletions(-) diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index f8c30f3..b4119f9 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -2312,18 +2312,6 @@ function buildComposeYamlSchema() { return jsyaml.DEFAULT_SCHEMA.extend(types); } - function hasComposeCustomYamlTag(content) { - if (!content) return false; - return /!(?:override|reset|merge)\b/.test(content) || /!<(?:override|reset|merge)>/.test(content); - } - - function stripComposeCustomYamlTags(content) { - if (!content) return content; - return content - .replace(/!<(?:override|reset|merge)>/g, '') - .replace(/!(?:override|reset|merge)\b/g, ''); - } - function loadComposeYaml(content) { var input = content || ''; @@ -2331,20 +2319,12 @@ function loadComposeYaml(content) { composeYamlSchemaCache = buildComposeYamlSchema(); } - try { - if (composeYamlSchemaCache) { - return jsyaml.load(input, { - schema: composeYamlSchemaCache - }); - } - return jsyaml.load(input); - } catch (e) { - var message = String((e && e.message) || ''); - if (/unknown tag/i.test(message) && hasComposeCustomYamlTag(input)) { - return jsyaml.load(stripComposeCustomYamlTags(input)); - } - throw e; + if (composeYamlSchemaCache) { + return jsyaml.load(input, { + schema: composeYamlSchemaCache + }); } + return jsyaml.load(input); } function applyDesc(myID) { var newDesc = $("#newDesc" + myID).val(); diff --git a/tests/unit/ComposeManagerMainSourceTest.php b/tests/unit/ComposeManagerMainSourceTest.php index 6404dd3..62c7a53 100644 --- a/tests/unit/ComposeManagerMainSourceTest.php +++ b/tests/unit/ComposeManagerMainSourceTest.php @@ -71,15 +71,7 @@ public function testComposeCustomTagSchemaSupportIsDeclared(): void { $source = $this->getPageSource(); $this->assertStringContainsString("var customTags = ['!override', '!reset', '!merge'];", $source); - $this->assertStringContainsString('function hasComposeCustomYamlTag(content)', $source); + $this->assertStringContainsString('function buildComposeYamlSchema()', $source); } - public function testUnknownTagFallbackIsScopedToComposeTags(): void - { - $source = $this->getPageSource(); - $this->assertStringContainsString('function stripComposeCustomYamlTags(content)', $source); - $this->assertStringContainsString('.replace(/!<(?:override|reset|merge)>/g, \'\')', $source); - $this->assertStringContainsString('.replace(/!(?:override|reset|merge)\\b/g, \'\')', $source); - $this->assertStringContainsString('if (/unknown tag/i.test(message) && hasComposeCustomYamlTag(input)) {', $source); - } } From 688508a75adf1cd14b134af2e2d62ccafe71fb03 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Fri, 10 Apr 2026 09:26:48 -0400 Subject: [PATCH 4/4] fix(yaml): harden js-yaml loading and prevent tagged override data loss --- .../include/ComposeManager.php | 50 ++++++++++++++++--- tests/unit/ComposeManagerMainSourceTest.php | 9 ++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index b4119f9..c384f77 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -2280,9 +2280,27 @@ function cancelDesc(myID) { } var composeYamlSchemaCache = null; + var composeYamlCustomTagPattern = /(^|[\s:[{,\-])!(override|reset|merge)\b/m; + + function getComposeYamlLibrary() { + if (typeof jsyaml !== 'undefined') { + return jsyaml; + } + + if (typeof window !== 'undefined' && window.jsyaml) { + return window.jsyaml; + } + + return null; + } + + function composeYamlContainsCustomTags(content) { + return composeYamlCustomTagPattern.test(content || ''); + } function buildComposeYamlSchema() { - if (!jsyaml || typeof jsyaml.Type !== 'function' || !jsyaml.DEFAULT_SCHEMA || typeof jsyaml.DEFAULT_SCHEMA.extend !== 'function') { + var yamlLib = getComposeYamlLibrary(); + if (!yamlLib || typeof yamlLib.Type !== 'function' || !yamlLib.DEFAULT_SCHEMA || typeof yamlLib.DEFAULT_SCHEMA.extend !== 'function') { return null; } @@ -2292,7 +2310,7 @@ function buildComposeYamlSchema() { customTags.forEach(function(tag) { kinds.forEach(function(kind) { - types.push(new jsyaml.Type(tag, { + types.push(new yamlLib.Type(tag, { kind: kind, resolve: function() { return true; @@ -2309,22 +2327,27 @@ function buildComposeYamlSchema() { }); }); - return jsyaml.DEFAULT_SCHEMA.extend(types); + return yamlLib.DEFAULT_SCHEMA.extend(types); } function loadComposeYaml(content) { var input = content || ''; + var yamlLib = getComposeYamlLibrary(); + + if (!yamlLib || typeof yamlLib.load !== 'function') { + throw new Error('YAML parser is unavailable. Please reload the page and try again.'); + } if (!composeYamlSchemaCache) { composeYamlSchemaCache = buildComposeYamlSchema(); } if (composeYamlSchemaCache) { - return jsyaml.load(input, { + return yamlLib.load(input, { schema: composeYamlSchemaCache }); } - return jsyaml.load(input); + return yamlLib.load(input); } function applyDesc(myID) { var newDesc = $("#newDesc" + myID).val(); @@ -4146,7 +4169,9 @@ function loadLabelsData() { editorModal.labelsData = { mainDoc: mainDoc, - overrideDoc: overrideDoc + overrideDoc: overrideDoc, + overrideContent: overrideData.content || '', + overrideHasCustomTags: composeYamlContainsCustomTags(overrideData.content || '') }; renderLabelsUI(mainDoc, overrideDoc); @@ -4469,7 +4494,7 @@ function saveAllChanges() { // Save labels if modified if (editorModal.modifiedLabels.size > 0) { - savePromises.push(saveLabels()); + savePromises.push(saveLabels(saveErrors)); } $.when.apply($, savePromises).then(function() { @@ -4623,13 +4648,20 @@ function saveSettings(saveErrors) { } // Save labels to override file - function saveLabels() { + function saveLabels(saveErrors) { var project = editorModal.currentProject; if (!editorModal.labelsData) { return $.Deferred().reject().promise(); } + if (editorModal.labelsData.overrideHasCustomTags) { + if (saveErrors) { + saveErrors.push('WebUI labels cannot be saved because compose.override.yaml uses !override, !reset, or !merge tags. Edit the override file directly to preserve those tags.'); + } + return $.Deferred().resolve(false).promise(); + } + var mainDoc = editorModal.labelsData.mainDoc; var overrideDoc = editorModal.labelsData.overrideDoc; @@ -4681,6 +4713,8 @@ function saveLabels() { editorModal.originalLabels[serviceKey + '_webui'] = $('#label-' + serviceKey + '-webui').val() || ''; editorModal.originalLabels[serviceKey + '_shell'] = $('#label-' + serviceKey + '-shell').val() || ''; } + editorModal.labelsData.overrideContent = rawOverride; + editorModal.labelsData.overrideHasCustomTags = false; editorModal.modifiedLabels.clear(); updateTabModifiedState(); updateSaveButtonState(); diff --git a/tests/unit/ComposeManagerMainSourceTest.php b/tests/unit/ComposeManagerMainSourceTest.php index 62c7a53..3bc4ace 100644 --- a/tests/unit/ComposeManagerMainSourceTest.php +++ b/tests/unit/ComposeManagerMainSourceTest.php @@ -72,6 +72,15 @@ public function testComposeCustomTagSchemaSupportIsDeclared(): void $source = $this->getPageSource(); $this->assertStringContainsString("var customTags = ['!override', '!reset', '!merge'];", $source); $this->assertStringContainsString('function buildComposeYamlSchema()', $source); + $this->assertStringContainsString("if (typeof jsyaml !== 'undefined') {", $source); + $this->assertStringContainsString("throw new Error('YAML parser is unavailable. Please reload the page and try again.');", $source); + } + + public function testLabelSaveBlocksTaggedOverrideRewrite(): void + { + $source = $this->getPageSource(); + $this->assertStringContainsString('overrideHasCustomTags: composeYamlContainsCustomTags(overrideData.content || \'\')', $source); + $this->assertStringContainsString('WebUI labels cannot be saved because compose.override.yaml uses !override, !reset, or !merge tags.', $source); } }