001/* 002 * Gridarta MMORPG map editor for Crossfire, Daimonin and similar games. 003 * Copyright (C) 2000-2010 The Gridarta Developers. 004 * 005 * This program is free software; you can redistribute it and/or modify 006 * it under the terms of the GNU General Public License as published by 007 * the Free Software Foundation; either version 2 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU General Public License for more details. 014 * 015 * You should have received a copy of the GNU General Public License along 016 * with this program; if not, write to the Free Software Foundation, Inc., 017 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 018 */ 019 020package net.sf.gridarta.model.validation.checks; 021 022import java.awt.Point; 023import java.io.File; 024import java.io.FileOutputStream; 025import java.io.IOException; 026import java.io.InputStreamReader; 027import java.io.OutputStream; 028import java.io.OutputStreamWriter; 029import java.io.Writer; 030import java.util.concurrent.Semaphore; 031import java.util.concurrent.TimeUnit; 032import java.util.regex.Matcher; 033import java.util.regex.Pattern; 034import net.sf.gridarta.model.archetype.Archetype; 035import net.sf.gridarta.model.gameobject.GameObject; 036import net.sf.gridarta.model.io.MapWriter; 037import net.sf.gridarta.model.maparchobject.MapArchObject; 038import net.sf.gridarta.model.mapmodel.MapModel; 039import net.sf.gridarta.model.mapmodel.MapSquare; 040import net.sf.gridarta.model.validation.AbstractValidator; 041import net.sf.gridarta.model.validation.ErrorCollector; 042import net.sf.gridarta.model.validation.MapValidator; 043import net.sf.gridarta.model.validation.ValidatorPreferences; 044import net.sf.gridarta.utils.CopyReader; 045import net.sf.gridarta.utils.IOUtils; 046import net.sf.gridarta.utils.StringUtils; 047import org.jetbrains.annotations.NotNull; 048import org.jetbrains.annotations.Nullable; 049 050/** 051 * Executes a script to check a map. The script's output is parsed and converted 052 * into validation errors. 053 * @author Andreas Kirschbaum 054 */ 055public class MapCheckerScriptChecker<G extends GameObject<G, A, R>, A extends MapArchObject<A>, R extends Archetype<G, A, R>> extends AbstractValidator<G, A, R> implements MapValidator<G, A, R> { 056 057 /** 058 * The maximum execution time of the checker script in milliseconds. If the 059 * script runs longer, it will be terminated. 060 */ 061 private static final int MAX_EXEC_TIME = 30000; 062 063 /** 064 * The placeholder in the command's arguments for the map to check. 065 */ 066 @NotNull 067 public static final String MAP_PLACEHOLDER = "${MAP}"; 068 069 /** 070 * The quoted. placeholder in the command's arguments for the map to check. 071 */ 072 @NotNull 073 private static final String QUOTED_MAP_PLACEHOLDER = Pattern.quote(MAP_PLACEHOLDER); 074 075 /** 076 * The {@link Pattern} for matching per map square messages. 077 */ 078 @NotNull 079 private static final Pattern PATTERN_MAP_SQUARE_MESSAGE = Pattern.compile("(\\d+)\\s+(\\d+)\\s+(.*)"); 080 081 /** 082 * The {@link MapWriter} for saving temporary map files. 083 */ 084 @NotNull 085 private final MapWriter<G, A, R> mapWriter; 086 087 /** 088 * The {@link CommandFinder} for the script to execute. 089 */ 090 @NotNull 091 private final CommandFinder commandFinder1 = new CommandFinder(); 092 093 /** 094 * The {@link CommandFinder} for the script interpreter. 095 */ 096 @NotNull 097 private final CommandFinder commandFinder2 = new CommandFinder(); 098 099 /** 100 * The command to execute and the arguments to pass. 101 */ 102 @NotNull 103 private final String[] args; 104 105 /** 106 * The temp file for saving maps to be checked. Set to <code>null</code> 107 * until created. 108 */ 109 @Nullable 110 private File tmpFile; 111 112 /** 113 * Creates a new instance. 114 * @param validatorPreferences the validator preferences to use 115 * @param mapWriter the map writer for saving temporary map files 116 * @param args the command to execute and the arguments to pass 117 */ 118 public MapCheckerScriptChecker(@NotNull final ValidatorPreferences validatorPreferences, @NotNull final MapWriter<G, A, R> mapWriter, @NotNull final String[] args) { 119 super(validatorPreferences); 120 this.mapWriter = mapWriter; 121 this.args = args.clone(); 122 123 if (this.args.length < 1) { 124 throw new IllegalArgumentException("no script to execute"); 125 } 126 127 boolean found = false; 128 for (int i = 1; i < this.args.length; i++) { 129 if (this.args[i].contains(MAP_PLACEHOLDER)) { 130 found = true; 131 break; 132 } 133 } 134 if (!found) { 135 throw new IllegalArgumentException("the script to execute doesn't receive the map to check (" + MAP_PLACEHOLDER + ")"); 136 } 137 } 138 139 /** 140 * {@inheritDoc} 141 */ 142 @Override 143 public void validateMap(@NotNull final MapModel<G, A, R> mapModel, @NotNull final ErrorCollector<G, A, R> errorCollector) { 144 final String[] command = getCommand(mapModel, errorCollector); 145 if (command == null) { 146 return; 147 } 148 final Process process; 149 try { 150 process = Runtime.getRuntime().exec(command); 151 } catch (final IOException ex) { 152 errorCollector.collect(new MapCheckerScriptMissingError<G, A, R>(mapModel, command[0], ex.getMessage())); 153 return; 154 } 155 @Nullable final String output; 156 try { 157 try { 158 process.getOutputStream().close(); 159 } catch (final IOException ex) { 160 errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command[0], ex.getMessage() + " (closing stdin)")); 161 return; 162 } 163 164 output = runProcess(process, errorCollector, mapModel, command[0]); 165 } finally { 166 process.destroy(); 167 } 168 if (output != null) { 169 parseOutput(output, errorCollector, mapModel, command[0]); 170 } 171 } 172 173 /** 174 * Parses output of the executed script. Adds validation error to an {@link 175 * ErrorCollector}. 176 * @param output the output to parse 177 * @param errorCollector the error collector to add to 178 * @param mapModel the map model being checked 179 * @param command the command being run 180 */ 181 private void parseOutput(@NotNull final CharSequence output, @NotNull final ErrorCollector<G, A, R> errorCollector, @NotNull final MapModel<G, A, R> mapModel, @NotNull final String command) { 182 final String[] lines = StringUtils.PATTERN_END_OF_LINE.split(output); 183 for (final String line : lines) { 184 if (!line.isEmpty()) { 185 final Matcher matcher = PATTERN_MAP_SQUARE_MESSAGE.matcher(line); 186 if (matcher.matches()) { 187 final int x; 188 final int y; 189 try { 190 x = Integer.parseInt(matcher.group(1)); 191 y = Integer.parseInt(matcher.group(2)); 192 } catch (final NumberFormatException ignored) { 193 errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, "syntax error in '" + line + "'")); 194 continue; 195 } 196 final String message = matcher.group(3); 197 final MapSquare<G, A, R> mapSquare; 198 try { 199 mapSquare = mapModel.getMapSquare(new Point(x, y)); 200 } catch (final IndexOutOfBoundsException ignored) { 201 errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, "invalid map square in '" + line + "'")); 202 continue; 203 } 204 errorCollector.collect(new MapCheckerScriptMapSquareError<G, A, R>(mapSquare, message)); 205 } else { 206 errorCollector.collect(new MapCheckerScriptMapError<G, A, R>(mapModel, line)); 207 } 208 } 209 } 210 } 211 212 /** 213 * Waits for a {@link Process} to terminate and returns its output. 214 * @param process the process 215 * @param errorCollector the error collector to add problems to 216 * @param mapModel the map model that is being checked 217 * @param command the command that is being run 218 * @return the command's output 219 */ 220 @Nullable 221 private String runProcess(@NotNull final Process process, @NotNull final ErrorCollector<G, A, R> errorCollector, @NotNull final MapModel<G, A, R> mapModel, @NotNull final String command) { 222 @Nullable String output; 223 final InputStreamReader stdoutReader = new InputStreamReader(process.getInputStream()); 224 try { 225 final CopyReader stdout = new CopyReader(stdoutReader); 226 final InputStreamReader stderrReader = new InputStreamReader(process.getErrorStream()); 227 try { 228 final CopyReader stderr = new CopyReader(stderrReader); 229 stdout.start(); 230 try { 231 stderr.start(); 232 try { 233 if (!waitForTermination(process, errorCollector, mapModel, command, stdout, stderr)) { 234 return null; 235 } 236 } finally { 237 stderr.stop(); 238 } 239 } finally { 240 stdout.stop(); 241 } 242 } finally { 243 try { 244 stderrReader.close(); 245 } catch (final IOException ex) { 246 errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, ex.getMessage() + " (closing stderr)")); 247 } 248 } 249 final String stdoutFailure = stdout.getFailure(); 250 if (stdoutFailure != null) { 251 errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, stdoutFailure)); 252 return null; 253 } 254 output = stdout.getOutput(); 255 } finally { 256 try { 257 stdoutReader.close(); 258 } catch (final IOException ex) { 259 errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, ex.getMessage() + " (closing stdout)")); 260 } 261 } 262 return output; 263 } 264 265 /** 266 * Waits for a {@link Process} to terminate. 267 * @param process the process 268 * @param errorCollector the error collector to add problems to 269 * @param mapModel the map model being checked 270 * @param command the command being executed 271 * @param stdout the stdout stream of the process 272 * @param stderr the stderr stream of the process 273 * @return whether the process did exit successfully 274 */ 275 private boolean waitForTermination(@NotNull final Process process, @NotNull final ErrorCollector<G, A, R> errorCollector, @NotNull final MapModel<G, A, R> mapModel, @NotNull final String command, @NotNull final CopyReader stdout, @NotNull final CopyReader stderr) { 276 final Semaphore sem = new Semaphore(0); 277 final int[] tmp = { -1, }; 278 final Runnable runnable = new Runnable() { 279 280 @Override 281 public void run() { 282 try { 283 try { 284 stdout.join(); 285 stderr.join(); 286 tmp[0] = process.waitFor(); 287 } catch (final InterruptedException ignored) { 288 Thread.currentThread().interrupt(); 289 } 290 } finally { 291 sem.release(); 292 } 293 } 294 295 }; 296 final Thread thread = new Thread(runnable); 297 thread.start(); 298 try { 299 if (!sem.tryAcquire(MAX_EXEC_TIME, TimeUnit.MILLISECONDS)) { 300 errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, "timeout waiting for script to terminate")); 301 return false; 302 } 303 } catch (final InterruptedException ignored) { 304 errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, "interrupted waiting for script to terminate")); 305 return false; 306 } 307 thread.interrupt(); 308 if (tmp[0] != 0) { 309 errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, "command exited with status " + tmp[0])); 310 return false; 311 } 312 final String stderrFailure = stderr.getFailure(); 313 if (stderrFailure != null) { 314 errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, stderrFailure)); 315 return false; 316 } 317 final String stderrOutput = stderr.getOutput(); 318 if (!stderrOutput.isEmpty()) { 319 errorCollector.collect(new MapCheckerScriptFailureError<G, A, R>(mapModel, command, StringUtils.PATTERN_NEWLINE.matcher(stderrOutput).replaceAll("<br>"))); 320 return false; 321 } 322 return true; 323 } 324 325 /** 326 * Returns the command to execute. Returns {@link #args} but replaces {@link 327 * #MAP_PLACEHOLDER} with the map to check. 328 * @param mapModel the map to check 329 * @param errorCollector the error collector for reporting problems 330 * @return the command to execute or <code>null</code> if an error occurred 331 */ 332 @Nullable 333 private String[] getCommand(@NotNull final MapModel<G, A, R> mapModel, @NotNull final ErrorCollector<G, A, R> errorCollector) { 334 if (tmpFile == null) { 335 try { 336 tmpFile = File.createTempFile("gridarta", null); 337 } catch (final IOException ex) { 338 errorCollector.collect(new MapCheckerScriptIOError<G, A, R>(mapModel, "create temporary file", ex.getMessage())); 339 return null; 340 } 341 assert tmpFile != null; 342 tmpFile.deleteOnExit(); 343 } 344 assert tmpFile != null; 345 final String mapPath = tmpFile.getPath(); 346 try { 347 final OutputStream outputStream = new FileOutputStream(tmpFile); 348 try { 349 final Writer writer = new OutputStreamWriter(outputStream, IOUtils.MAP_ENCODING); 350 try { 351 mapWriter.encodeMapFile(mapModel, writer); 352 } finally { 353 writer.close(); 354 } 355 } finally { 356 outputStream.close(); 357 } 358 } catch (final IOException ex) { 359 errorCollector.collect(new MapCheckerScriptIOError<G, A, R>(mapModel, mapPath, ex.getMessage())); 360 return null; 361 } 362 363 final boolean isWindows = System.getProperty("os.name").contains("Windows"); 364 final String[] result; 365 int index = 0; 366 try { 367 if (isWindows) { 368 if (args[0].toLowerCase().endsWith(".py")) { 369 result = new String[args.length + 1]; 370 String command; 371 try { 372 command = commandFinder2.getCommand("python.exe"); 373 } catch (final IOException ex) { 374 command = "C:" + File.separator + "python27" + File.separator + "python.exe"; 375 if (!new File(command).exists()) { 376 throw ex; 377 } 378 } 379 result[index++] = "\"" + command + "\""; 380 } else { 381 result = new String[args.length]; 382 } 383 result[index++] = "\"" + commandFinder1.getCommand(args[0]) + "\""; 384 } else { 385 result = new String[args.length]; 386 result[index++] = commandFinder1.getCommand(args[0]); 387 } 388 } catch (final IOException ex) { 389 errorCollector.collect(new MapCheckerScriptMissingError<G, A, R>(mapModel, args[0], ex.getMessage())); 390 return null; 391 } 392 final String mapPathQuoted = Matcher.quoteReplacement(mapPath); 393 for (int i = 1; i < args.length; i++) { 394 final String tmp = args[i].replaceAll(QUOTED_MAP_PLACEHOLDER, mapPathQuoted); 395 result[index++] = isWindows ? "\"" + tmp + "\"" : tmp; 396 } 397 return result; 398 } 399 400 /** 401 * Searches for commands in the PATH environment variable. 402 * @author Andreas Kirschbaum 403 */ 404 private static class CommandFinder { 405 406 /** 407 * The command to execute. Set to <code>null</code> if unknown. 408 * Otherwise points to <code>{@link #args}[0]</code>. 409 */ 410 @Nullable 411 private File cachedCommand; 412 413 /** 414 * Returns the command to execute. Returns or updates {@link 415 * #cachedCommand}. 416 * @param commandName the command name to search 417 * @return the command to execute 418 * @throws IOException if the command cannot be found 419 */ 420 @NotNull 421 private String getCommand(@NotNull final String commandName) throws IOException { 422 final File existingCommand = cachedCommand; 423 if (existingCommand != null && existingCommand.exists()) { 424 return existingCommand.getPath(); 425 } 426 427 final File command = IOUtils.findPathFile(commandName); 428 cachedCommand = command; 429 return command.getPath(); 430 } 431 432 } 433 434}