filebot/source/net/filebot/format/ExpressionFormat.java

306 lines
8.0 KiB
Java

package net.filebot.format;
import static net.filebot.util.ExceptionUtilities.*;
import static net.filebot.util.FileUtilities.*;
import java.io.File;
import java.security.AccessController;
import java.text.FieldPosition;
import java.text.Format;
import java.text.ParsePosition;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import javax.lang.model.SourceVersion;
import javax.script.Bindings;
import javax.script.Compilable;
import javax.script.CompiledScript;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptException;
import javax.script.SimpleScriptContext;
import org.codehaus.groovy.control.CompilerConfiguration;
import org.codehaus.groovy.control.MultipleCompilationErrorsException;
import org.codehaus.groovy.jsr223.GroovyScriptEngineImpl;
import groovy.lang.GroovyClassLoader;
import groovy.lang.GroovyRuntimeException;
import groovy.lang.MissingPropertyException;
public class ExpressionFormat extends Format {
private final String expression;
private final Object[] compilation;
private SuppressedThrowables suppressed;
public ExpressionFormat(String expression) throws ScriptException {
this.expression = expression;
this.compilation = secure(compile(asExpression(expression)));
}
public String getExpression() {
return expression;
}
protected Object[] compile(String expression) throws ScriptException {
List<Object> compilation = new ArrayList<Object>();
char open = '{';
char close = '}';
StringBuilder token = new StringBuilder();
int level = 0;
// parse expressions and literals
for (int i = 0; i < expression.length(); i++) {
char c = expression.charAt(i);
if (c == open) {
if (level == 0) {
if (token.length() > 0) {
compilation.add(token.toString());
token.setLength(0);
}
} else {
token.append(c);
}
level++;
} else if (c == close) {
if (level == 1) {
if (token.length() > 0) {
try {
compilation.add(compileScriptlet(token.toString()));
} catch (ScriptException e) {
// try to extract syntax exception
ScriptException illegalSyntax = e;
try {
String message = findCause(e, MultipleCompilationErrorsException.class).getErrorCollector().getSyntaxError(0).getOriginalMessage();
illegalSyntax = new ScriptException("SyntaxError: " + message);
} catch (Exception ignore) {
// ignore, just use original exception
}
throw illegalSyntax;
} finally {
token.setLength(0);
}
}
} else {
token.append(c);
}
level--;
} else {
token.append(c);
}
// sanity check
if (level < 0) {
throw new ScriptException("SyntaxError: unexpected token: " + close);
}
}
// sanity check
if (level != 0) {
throw new ScriptException("SyntaxError: missing token: " + close);
}
// append tail
if (token.length() > 0) {
compilation.add(token.toString());
}
return compilation.toArray();
}
public Bindings getBindings(Object value) {
return new ExpressionBindings(value);
}
@Override
public StringBuffer format(Object object, StringBuffer sb, FieldPosition pos) {
return sb.append(format(getBindings(object)));
}
public String format(Bindings bindings) {
// use privileged bindings so we are not restricted by the script sandbox
Bindings priviledgedBindings = PrivilegedInvocation.newProxy(Bindings.class, bindings, AccessController.getContext());
// initialize script context with the privileged bindings
ScriptContext context = new SimpleScriptContext();
context.setBindings(priviledgedBindings, ScriptContext.GLOBAL_SCOPE);
// reset exception state
List<Throwable> suppressed = new ArrayList<Throwable>();
StringBuilder sb = new StringBuilder();
for (Object snippet : compilation) {
if (snippet instanceof CompiledScript) {
try {
CharSequence value = normalizeExpressionValue(((CompiledScript) snippet).eval(context));
if (value != null) {
sb.append(value);
}
} catch (ScriptException e) {
suppressed.add(normalizeExpressionException(e));
}
} else {
sb.append(snippet);
}
}
// require non-empty String value
String value = normalizeResult(sb);
if (value.isEmpty()) {
throw new SuppressedThrowables("Expression yields empty value", suppressed);
}
// store for later (not thread-safe)
this.suppressed = suppressed.isEmpty() ? null : new SuppressedThrowables("Suppressed", suppressed);
return value;
}
public SuppressedThrowables suppressed() {
return suppressed;
}
protected Object normalizeBindingValue(Object value) {
return value;
}
protected CharSequence normalizeExpressionValue(Object value) {
return value == null ? null : value.toString();
}
protected String normalizeResult(CharSequence value) {
return value.toString();
}
protected Throwable normalizeExpressionException(ScriptException exception) {
if (findCause(exception, MissingPropertyException.class) != null) {
return new BindingException(findCause(exception, MissingPropertyException.class).getProperty(), "undefined", exception);
}
if (findCause(exception, GroovyRuntimeException.class) != null) {
return new ExpressionException(findCause(exception, GroovyRuntimeException.class).getMessage(), exception);
}
// unwrap ScriptException if possible
if (exception.getCause() instanceof Exception) {
return exception.getCause();
}
return exception;
}
@Override
public Object parseObject(String source, ParsePosition pos) {
throw new UnsupportedOperationException();
}
private Object[] secure(Object[] compilation) {
for (int i = 0; i < compilation.length; i++) {
Object snippet = compilation[i];
// simple expressions like {n} can't contain any malicious code
if (snippet instanceof Variable) {
continue;
}
if (snippet instanceof CompiledScript) {
compilation[i] = new SecureCompiledScript((CompiledScript) snippet);
}
}
return compilation;
}
protected static Compilable createScriptEngine() {
GroovyClassLoader classLoader = new GroovyClassLoader(Thread.currentThread().getContextClassLoader(), new CompilerConfiguration());
return new GroovyScriptEngineImpl(classLoader);
}
private static Compilable engine;
private static Map<String, CompiledScript> scriptletCache = new HashMap<String, CompiledScript>();
protected static CompiledScript compileScriptlet(String expression) throws ScriptException {
// simple expressions like {n} don't need to be interpreted by the script engine
if (SourceVersion.isIdentifier(expression) && !SourceVersion.isKeyword(expression)) {
return new Variable(expression);
}
synchronized (scriptletCache) {
CompiledScript scriptlet = scriptletCache.get(expression);
if (scriptlet == null) {
// lazy initialize script engine
if (engine == null) {
engine = createScriptEngine();
}
// compile and cache script
scriptlet = engine.compile(expression);
scriptletCache.put(expression, scriptlet);
}
return scriptlet;
}
}
protected static String asExpression(String s) {
// try as file path
if (s.startsWith("@") && s.endsWith(".groovy")) {
File f = new File(s.substring(1));
if (f.isFile()) {
try {
return readTextFile(f);
} catch (Exception e) {
throw new IllegalArgumentException("Failed to read text file: " + f, e);
}
}
}
// or default to literal value
return s;
}
private static class Variable extends CompiledScript {
private String name;
public Variable(String name) {
this.name = name;
}
@Override
public Object eval(ScriptContext context) throws ScriptException {
try {
Object value = context.getAttribute(name);
if (value == null) {
throw new MissingPropertyException(name, Variable.class);
}
return value;
} catch (Exception e) {
throw new ScriptException(e);
} catch (Throwable t) {
throw new ScriptException(new ExecutionException(t));
}
}
@Override
public ScriptEngine getEngine() {
return null;
}
}
}