From 493c32d12ebed76052212fd2531dd2d257fac834 Mon Sep 17 00:00:00 2001 From: "Felix W. Dekker" Date: Mon, 4 Nov 2019 14:47:59 +0100 Subject: [PATCH] Fix #40 And * Fix absolute paths with rmdir * Fix inverted behaviour of -n with echo * Store environment variables in a session-based cookie * Improve escaped behaviour of > characters * Simplify input parser's tokeniser --- src/main/js/Commands.ts | 33 ++++++- src/main/js/FileSystem.ts | 2 +- src/main/js/Shell.ts | 168 +++++++++++++++++++++-------------- src/test/InputParser.spec.ts | 42 ++++++++- 4 files changed, 173 insertions(+), 72 deletions(-) diff --git a/src/main/js/Commands.ts b/src/main/js/Commands.ts index 47a8c6f..7cecb43 100644 --- a/src/main/js/Commands.ts +++ b/src/main/js/Commands.ts @@ -2,7 +2,7 @@ import * as Cookies from "js-cookie"; import "./Extensions" import {File, FileSystem} from "./FileSystem" import {IllegalStateError, stripHtmlTags} from "./Shared"; -import {InputArgs} from "./Shell"; +import {Environment, InputArgs} from "./Shell"; import {EscapeCharacters} from "./Terminal"; import {UserSession} from "./UserSession"; @@ -11,6 +11,10 @@ import {UserSession} from "./UserSession"; * A collection of commands executed within a particular user session. */ export class Commands { + /** + * The environment in which commands are executed. + */ + private readonly environment: Environment; /** * The user session describing the user that executes commands. */ @@ -28,10 +32,12 @@ export class Commands { /** * Constructs a new collection of commands executed within the given user session. * + * @param environment the environment in which commands are executed * @param userSession the user session describing the user that executes commands * @param fileSystem the file system to interact with */ - constructor(userSession: UserSession, fileSystem: FileSystem) { + constructor(environment: Environment, userSession: UserSession, fileSystem: FileSystem) { + this.environment = environment; this.userSession = userSession; this.fileSystem = fileSystem; this.commands = { @@ -171,6 +177,14 @@ export class Commands { If more than one directory is given, the directories are removed in the order they are given in.`.trimLines(), new InputValidator({minArgs: 1}) ), + "set": new Command( + this.set, + `set environment variable`, + `set key [value]`, + `Sets the environment variable with the given key to the given value. + If no value is given, the environment variable is cleared.`.trimLines(), + new InputValidator({minArgs: 1, maxArgs: 2}) + ), "touch": new Command( this.touch, `change file timestamps`, @@ -202,6 +216,7 @@ export class Commands { Cookies.remove("files"); Cookies.remove("cwd"); Cookies.remove("user"); + Cookies.remove("env"); location.reload(); throw new Error("Goodbye"); } @@ -265,7 +280,7 @@ export class Commands { private echo(input: InputArgs): string { return input.args.join(" ").replace("hunter2", "*******") - + (input.hasOption("n") ? "\n" : ""); + + (input.hasOption("n") ? "" : "\n"); } private exit(): string { @@ -399,6 +414,18 @@ export class Commands { return this.fileSystem.rmdirs(input.args); } + private set(input: InputArgs): string { + if (!input.args[0].match(/^[0-9a-z_]+$/i)) + return "Environment variable keys can only contain alphanumerical characters and underscores."; + + if (input.args.length === 1) + delete this.environment[input.args[0]]; + else + this.environment[input.args[0]] = input.args[1]; + + return ""; + } + private touch(input: InputArgs): string { return this.fileSystem.createFiles(input.args); } diff --git a/src/main/js/FileSystem.ts b/src/main/js/FileSystem.ts index 0c11bb5..c421b7b 100644 --- a/src/main/js/FileSystem.ts +++ b/src/main/js/FileSystem.ts @@ -412,7 +412,7 @@ export class FileSystem { * @return an empty string if the removal was successful, or a message explaining what went wrong */ private rmdir(pathString: string): string { - const path = this._cwd.getChild(pathString); + const path = this.getPathTo(pathString); if (path.toString() === "/") { if (this.root.nodeCount > 0) diff --git a/src/main/js/Shell.ts b/src/main/js/Shell.ts index 1c3e6da..e122b12 100644 --- a/src/main/js/Shell.ts +++ b/src/main/js/Shell.ts @@ -10,12 +10,16 @@ import {UserSession} from "./UserSession"; * A shell that interacts with the user session and file system to execute commands. */ export class Shell { + /** + * The environment in which commands are executed. + */ + private readonly environment: Environment; /** * The history of the user's inputs. */ private readonly inputHistory: InputHistory; /** - * The user session describing the user that interacts with the terminal. + * The user session describing the user that interacts with the shell. */ private readonly userSession: UserSession; /** @@ -76,7 +80,16 @@ export class Shell { console.warn("Failed to set cwd from cookie."); } - this.commands = new Commands(this.userSession, this.fileSystem); + // Read environment from cookie + const env = Cookies.get("env") || "{}"; + try { + this.environment = JSON.parse(env); + } catch (error) { + console.warn("Failed to set environment from cookie."); + this.environment = {}; + } + + this.commands = new Commands(this.environment, this.userSession, this.fileSystem); } @@ -134,7 +147,7 @@ export class Shell { * * @param inputString the input to process */ - execute(inputString: string): string { + execute(inputString: string): string { if (!this.userSession.isLoggedIn) { if (this.attemptUser === undefined) { this.attemptUser = inputString.trim(); @@ -153,7 +166,7 @@ export class Shell { this.inputHistory.addEntry(inputString.trim()); - const input = new InputParser().parse(stripHtmlTags(inputString)); + const input = new InputParser(this.environment).parse(stripHtmlTags(inputString)); if (input.redirectTarget[0] === "write") { const rms = this.fileSystem.rms([input.redirectTarget[1]], true); if (rms !== "") @@ -208,6 +221,7 @@ export class Shell { "path": "/" }); Cookies.set("cwd", this.fileSystem.cwd, {"path": "/"}); + Cookies.set("env", this.environment, {"path": "/"}); const user = this.userSession.currentUser; Cookies.set("user", user === undefined ? "" : user.name, {"path": "/"}); @@ -215,6 +229,11 @@ export class Shell { } +/** + * A set of environment variables. + */ +export type Environment = { [key: string]: string } + /** * The options given to a command. */ @@ -327,6 +346,22 @@ export class InputArgs { * A parser for input strings. */ export class InputParser { + /** + * The environment containing the variables to substitute. + */ + private readonly environment: Environment; + + + /** + * Constructs a new input parser. + * + * @param environment the environment containing the variables to substitute + */ + constructor(environment: Environment) { + this.environment = environment; + } + + /** * Parses the given input string to a set of command-line arguments. * @@ -337,11 +372,7 @@ export class InputParser { const tokens = this.tokenize(input); const command = tokens[0] || ""; const [options, args] = - this.parseOpts( - tokens.slice(1) - .filter(it => !it.startsWith(">")) - .map(it => it.replace(/\\>/, ">")) - ); + this.parseOpts(tokens.slice(1).filter(it => !it.startsWith(`${EscapeCharacters.Escape}`))); const redirectTarget = this.getRedirectTarget(tokens.slice(1)); return new InputArgs(command, options, args, redirectTarget); @@ -349,10 +380,28 @@ export class InputParser { /** - * Returns the first token present in the given string. + * Tokenizes the input string. + * + * @param input the string to tokenize + * @return the array of tokens found in the input string + */ + private tokenize(input: string): string[] { + const tokens = []; + + while (input !== "") { + let token; + [token, input] = this.getNextToken(input); + tokens.push(token); + } + + return tokens; + } + + /** + * Returns the first token in the given string and the remaining string. * * @param input the string of which to return the first token - * @return the first token present in the given string + * @return the first token in the given string and the remaining string */ private getNextToken(input: string): [string, string] { let token = ""; @@ -366,32 +415,10 @@ export class InputParser { throw new Error("Unexpected end of input. `\\` was used but there was nothing to escape."); const nextChar = input[i + 1]; - switch (nextChar) { - case "\\": - token += "\\"; - break; - case "/": - if (isInSingleQuotes || isInDoubleQuotes) - token += "\\/"; - else - token += "/"; - break; - case "'": - token += "'"; - break; - case "\"": - token += "\""; - break; - case " ": - token += " "; - break; - case ">": - token += "\\>"; - break; - default: - token += "\\" + nextChar; - break; - } + if (isInSingleQuotes || isInDoubleQuotes) + token += "\\" + nextChar; + else + token += nextChar; i++; break; case "'": @@ -413,22 +440,28 @@ export class InputParser { return [token, input.slice(i + 1)]; break; case ">": - if (!isInSingleQuotes && !isInDoubleQuotes) { - if (token !== "") - return [token, input.slice(i)]; - - if (i !== input.length - 1 && input[i + 1] === ">") { - const token = this.getNextToken(input.slice(i + 2)); - token[0] = ">>" + token[0]; - return token; - } else { - const token = this.getNextToken(input.slice(i + 1)); - token[0] = ">" + token[0]; - return token; - } - } else { - token += "\\" + char; + if (isInSingleQuotes || isInDoubleQuotes) { + token += char; + break; } + + // Flush current token if not empty + if (token !== "") + return [token, input.slice(i)]; + + if (i !== input.length - 1 && input[i + 1] === ">") { + const token = this.getNextToken(input.slice(i + 2)); + token[0] = `${EscapeCharacters.Escape}>>${token[0]}`; + return token; + } else { + const token = this.getNextToken(input.slice(i + 1)); + token[0] = `${EscapeCharacters.Escape}>${token[0]}`; + return token; + } + case "$": + const nextVariable = this.getNextVariable(input.slice(i + 1)); + token += nextVariable[0]; + i += nextVariable[1]; break; default: token += char; @@ -443,21 +476,22 @@ export class InputParser { } /** - * Tokenizes the input string. + * Returns the value of the first environment variable in the given string and the length of the variable name. * - * @param input the string to tokenize - * @return the array of tokens found in the input string + * @param input the string to find the first environment variable in + * @return the value of the first environment variable in the given string and the length of the variable name */ - private tokenize(input: string): string[] { - const tokens = []; + private getNextVariable(input: string): [string, number] { + let variable = ""; + let i: number; + for (i = 0; i < input.length; i++) { + const char = input[i]; + if (!char.match(/[0-9a-z_]/i)) + break; - while (input !== "") { - let token; - [token, input] = this.getNextToken(input); - tokens.push(token); + variable += char; } - - return tokens; + return [this.environment[variable] || "", i]; } /** @@ -472,10 +506,10 @@ export class InputParser { let redirectTarget: ["default"] | ["write" | "append", string] = ["default"]; tokens.forEach(token => { - if (token.startsWith(">>")) - redirectTarget = ["append", token.slice(2)]; - else if (token.startsWith(">")) - redirectTarget = ["write", token.slice(1)]; + if (token.startsWith(`${EscapeCharacters.Escape}>>`)) + redirectTarget = ["append", token.slice(3)]; + else if (token.startsWith(`${EscapeCharacters.Escape}>`)) + redirectTarget = ["write", token.slice(2)]; }); return redirectTarget; diff --git a/src/test/InputParser.spec.ts b/src/test/InputParser.spec.ts index 105947d..f0fe659 100644 --- a/src/test/InputParser.spec.ts +++ b/src/test/InputParser.spec.ts @@ -8,10 +8,12 @@ import {InputParser} from "../main/js/Shell"; describe("input args", () => { let parser: InputParser; + beforeEach(() => { - parser = new InputParser(); + parser = new InputParser({}); }); + describe("tokenization", () => { it("concatenates multiple strings into one token", () => { expect(parser.parse(`'co'm"m nd"`).command).to.equal("comm nd"); @@ -24,6 +26,11 @@ describe("input args", () => { it("includes escaped quotation marks into the token", () => { expect(parser.parse(`com\\'man\\"d`).command).to.equal(`com'man"d`); }); + + it("does not escape inside strings", () => { + expect(parser.parse(`\\n`).command).to.equal("n"); + expect(parser.parse(`"\\n"`).command).to.equal("\\n"); + }); }); describe("command", () => { @@ -206,4 +213,37 @@ describe("input args", () => { expect(inputArgs.args).to.have.members([">", "file"]); }); }); + + describe("environment", () => { + beforeEach(() => { + parser = new InputParser({a: "b", aa: "c", r: ">"}); + }); + + + it("substitutes a known environment variable with its value", () => { + expect(parser.parse("$a").command).to.equal("b"); + }); + + it("substitutes an unknown environment variable with nothing", () => { + expect(parser.parse("$b").command).to.equal(""); + }); + + it("substitutes consecutive known environment variables with their value", () => { + expect(parser.parse("$a$aa$a").command).to.equal("bcb"); + }); + + it("substitutes nameless environment variables with nothing", () => { + expect(parser.parse("$$$").command).to.equal(""); + }); + + it("substitutes known environment variables that are in the middle of a string", () => { + expect(parser.parse("a'$a'c").command).to.equal("abc"); + }); + + it("substitutes special characters without interpreting them", () => { + const inputArgs = parser.parse("command $r file"); + expect(inputArgs.args).to.have.members([">", "file"]); + expect(inputArgs.redirectTarget).to.have.members(["default"]); + }); + }); });