feat(regex): add .NET regex validation via PowerShell and integrate into frontend

This commit is contained in:
Sam Chau
2025-08-27 01:34:10 +09:30
parent 99925be174
commit ef86fa251f
8 changed files with 266 additions and 13 deletions

View File

@@ -1,13 +1,28 @@
# Dockerfile
FROM python:3.9-slim
WORKDIR /app
# Install git and gosu for user switching
RUN apt-get update && apt-get install -y git gosu && rm -rf /var/lib/apt/lists/*
# Install git, gosu, and PowerShell Core
RUN apt-get update && apt-get install -y \
git \
gosu \
wget \
ca-certificates \
libicu-dev \
&& wget -O /tmp/powershell.tar.gz https://github.com/PowerShell/PowerShell/releases/download/v7.4.0/powershell-7.4.0-linux-x64.tar.gz \
&& mkdir -p /opt/microsoft/powershell/7 \
&& tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 \
&& chmod +x /opt/microsoft/powershell/7/pwsh \
&& ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh \
&& rm /tmp/powershell.tar.gz \
&& rm -rf /var/lib/apt/lists/*
# Copy pre-built files from dist directory
COPY dist/backend/app ./app
COPY dist/backend/scripts ./app/scripts
COPY dist/static ./app/static
COPY dist/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Ensure scripts are executable
RUN chmod +x /app/scripts/*.ps1 || true
# Copy and setup entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

View File

@@ -1,7 +1,21 @@
FROM python:3.9
WORKDIR /app
# Install PowerShell Core
RUN apt-get update && apt-get install -y \
wget \
ca-certificates \
libicu-dev \
&& wget -O /tmp/powershell.tar.gz https://github.com/PowerShell/PowerShell/releases/download/v7.4.0/powershell-7.4.0-linux-x64.tar.gz \
&& mkdir -p /opt/microsoft/powershell/7 \
&& tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 \
&& chmod +x /opt/microsoft/powershell/7/pwsh \
&& ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh \
&& rm /tmp/powershell.tar.gz \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Ensure scripts are executable
RUN chmod +x /app/scripts/*.ps1 || true
# Use gunicorn with 10-minute timeout
CMD ["python", "-m", "app.main"]

View File

@@ -226,6 +226,32 @@ def handle_item(category, name):
return jsonify({"error": "An unexpected error occurred"}), 500
@bp.route('/regex/verify', methods=['POST'])
def verify_regex():
"""Verify a regex pattern using .NET regex engine via PowerShell"""
try:
data = request.get_json()
if not data:
return jsonify({"error": "No JSON data provided"}), 400
pattern = data.get('pattern')
if not pattern:
return jsonify({"error": "Pattern is required"}), 400
from .utils import verify_dotnet_regex
success, message = verify_dotnet_regex(pattern)
if success:
return jsonify({"valid": True, "message": "Pattern is valid"}), 200
else:
return jsonify({"valid": False, "error": message}), 200
except Exception as e:
logger.exception("Error verifying regex pattern")
return jsonify({"valid": False, "error": str(e)}), 500
@bp.route('/<string:category>/test', methods=['POST'])
def run_tests(category):
logger.info(f"Received test request for category: {category}")

View File

@@ -7,6 +7,8 @@ from typing import Dict, List, Any, Tuple, Union
import git
import regex
import logging
import subprocess
import json
from ..db.queries.arr import update_arr_config_on_rename, update_arr_config_on_delete
logger = logging.getLogger(__name__)
@@ -360,6 +362,68 @@ def check_delete_constraints(category: str, name: str) -> Tuple[bool, str]:
return False, f"Error checking references: {str(e)}"
def verify_dotnet_regex(pattern: str) -> Tuple[bool, str]:
"""
Verify a regex pattern using .NET regex engine via PowerShell.
Returns (success, message) tuple.
"""
try:
# Get the path to the validate.ps1 script
# In Docker, the structure is /app/app/data/utils.py and script is at /app/scripts/validate.ps1
script_path = os.path.join('/app', 'scripts', 'validate.ps1')
if not os.path.exists(script_path):
# Fallback for local development
script_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'scripts', 'validate.ps1')
# Run PowerShell script, passing pattern via stdin to avoid shell escaping issues
result = subprocess.run(
['pwsh', '-File', script_path],
input=pattern,
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0 and not result.stdout:
logger.error(f"PowerShell script failed: {result.stderr}")
return False, "Failed to validate pattern"
# Log the raw output for debugging
logger.debug(f"PowerShell output: {result.stdout}")
# Parse JSON output
try:
output = json.loads(result.stdout.strip())
except json.JSONDecodeError:
# Try to find JSON in the output
lines = result.stdout.strip().split('\n')
for line in reversed(lines):
if line.strip():
try:
output = json.loads(line)
break
except json.JSONDecodeError:
continue
else:
logger.error(f"No valid JSON found in output: {result.stdout}")
return False, "Failed to parse validation result"
if output.get('valid'):
return True, output.get('message', 'Pattern is valid')
else:
return False, output.get('error', 'Invalid pattern')
except subprocess.TimeoutExpired:
logger.error("Pattern validation timed out")
return False, "Pattern validation timed out"
except FileNotFoundError:
logger.error("PowerShell (pwsh) not found")
return False, "PowerShell is not available"
except Exception as e:
logger.error(f"Error validating pattern: {e}")
return False, f"Validation error: {str(e)}"
def update_references(category: str, old_name: str,
new_name: str) -> List[str]:
"""

73
backend/scripts/validate.ps1 Executable file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env pwsh
# Validate a .NET regex pattern
param(
[Parameter(Mandatory=$false)]
[string]$Pattern
)
# Set output encoding to UTF-8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = "Stop"
# Read pattern from stdin if not provided as parameter
if (-not $Pattern) {
$Pattern = [System.Console]::In.ReadToEnd()
}
# Ensure we have a pattern
if ([string]::IsNullOrWhiteSpace($Pattern)) {
$result = @{
valid = $false
error = "No pattern provided"
}
Write-Output (ConvertTo-Json $result -Compress)
exit 0
}
try {
# Attempt to create a .NET Regex object with the pattern
# Using IgnoreCase option as per requirement
$regex = [System.Text.RegularExpressions.Regex]::new($Pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
# If we get here, the pattern is valid
$result = @{
valid = $true
message = "Pattern is valid .NET regex"
}
Write-Output (ConvertTo-Json $result -Compress)
exit 0
}
catch {
# Pattern is invalid, extract the meaningful part of the error message
$errorMessage = $_.Exception.Message
# Try to extract just the useful part of .NET regex errors
if ($errorMessage -match "Invalid pattern '.*?' at offset (\d+)\. (.+)") {
$errorMessage = "At position $($matches[1]): $($matches[2])"
}
elseif ($errorMessage -match 'parsing ".*?" - (.+)') {
$errorMessage = $matches[1]
}
elseif ($errorMessage -match 'Exception calling .* with .* argument\(s\): "(.+)"') {
$innerError = $matches[1]
if ($innerError -match "Invalid pattern '.*?' at offset (\d+)\. (.+)") {
$errorMessage = "At position $($matches[1]): $($matches[2])"
}
else {
$errorMessage = $innerError
}
}
# Remove any trailing quotes or periods followed by quotes
$errorMessage = $errorMessage -replace '\."$', '.' -replace '"$', ''
$result = @{
valid = $false
error = $errorMessage
}
Write-Output (ConvertTo-Json $result -Compress)
exit 0
}

View File

@@ -301,5 +301,15 @@ export const RegexPatterns = {
update: (name, data, newName) =>
updateItem('regex_pattern', name, data, newName),
delete: name => deleteItem('regex_pattern', name),
runTests: createSpecialEndpoint('regex_pattern', 'test')
runTests: createSpecialEndpoint('regex_pattern', 'test'),
verify: async pattern => {
try {
const response = await axios.post(`${BASE_URL}/regex/verify`, {
pattern
});
return response.data;
} catch (error) {
throw handleError(error, 'verify regex pattern');
}
}
};

View File

@@ -2,7 +2,9 @@ import React, {useState} from 'react';
import PropTypes from 'prop-types';
import MarkdownEditor from '@ui/MarkdownEditor';
import AddButton from '@ui/DataBar/AddButton';
import {InfoIcon} from 'lucide-react';
import {Regex, Loader} from 'lucide-react';
import {RegexPatterns} from '@api/data';
import Alert from '@ui/Alert';
const RegexGeneralTab = ({
name,
@@ -18,6 +20,7 @@ const RegexGeneralTab = ({
patternError
}) => {
const [newTag, setNewTag] = useState('');
const [validating, setValidating] = useState(false);
const handleAddTag = () => {
if (newTag.trim() && !tags.includes(newTag.trim())) {
@@ -33,6 +36,30 @@ const RegexGeneralTab = ({
}
};
const handleValidatePattern = async () => {
if (!pattern?.trim()) {
Alert.warning('Please enter a pattern to validate');
return;
}
setValidating(true);
try {
const result = await RegexPatterns.verify(pattern);
if (result.valid) {
Alert.success('Pattern is valid .NET regex');
} else {
Alert.error(result.error || 'Invalid pattern');
}
} catch (error) {
console.error('Validation error:', error);
Alert.error('Failed to validate pattern');
} finally {
setValidating(false);
}
};
return (
<div className='w-full'>
{error && (
@@ -89,17 +116,28 @@ const RegexGeneralTab = ({
<div className='space-y-2'>
<div className='space-y-1'>
<div className='flex items-center justify-between'>
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
Pattern
</label>
<div className='flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400'>
<InfoIcon className='h-4 w-4' />
<span>Case insensitive PCRE2</span>
<div>
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
Pattern
</label>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Enter your regular expression pattern (case-insensitive .NET)
</p>
</div>
<button
onClick={handleValidatePattern}
disabled={validating || !pattern?.trim()}
className='inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md
bg-blue-600 hover:bg-blue-700 disabled:bg-blue-600/50 text-white
transition-colors duration-200'>
{validating ? (
<Loader className='w-4 h-4 mr-2 animate-spin' />
) : (
<Regex className='w-4 h-4 mr-2' />
)}
Validate
</button>
</div>
<p className='text-xs text-gray-500 dark:text-gray-400'>
Enter your regular expression pattern
</p>
</div>
{patternError && (
<p className='text-sm text-red-600 dark:text-red-400'>

View File

@@ -65,6 +65,19 @@ export const useRegexModal = (initialPattern, onSave) => {
return;
}
// Validate pattern with .NET regex engine
try {
const validationResult = await RegexPatterns.verify(patternValue);
if (!validationResult.valid) {
Alert.error(`Invalid regex pattern: ${validationResult.error || 'Pattern validation failed'}`);
return;
}
} catch (error) {
console.error('Pattern validation error:', error);
Alert.error('Failed to validate pattern. Please check the pattern and try again.');
return;
}
try {
const data = {
name,