forked from tools/josh
1
0
Fork 0
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
This commit is contained in:
Florine W. Dekker 2019-11-04 14:47:59 +01:00
parent c51defad04
commit 493c32d12e
Signed by: FWDekker
GPG Key ID: B1B567AF58D6EE0F
4 changed files with 173 additions and 72 deletions

View File

@ -2,7 +2,7 @@ import * as Cookies from "js-cookie";
import "./Extensions" import "./Extensions"
import {File, FileSystem} from "./FileSystem" import {File, FileSystem} from "./FileSystem"
import {IllegalStateError, stripHtmlTags} from "./Shared"; import {IllegalStateError, stripHtmlTags} from "./Shared";
import {InputArgs} from "./Shell"; import {Environment, InputArgs} from "./Shell";
import {EscapeCharacters} from "./Terminal"; import {EscapeCharacters} from "./Terminal";
import {UserSession} from "./UserSession"; import {UserSession} from "./UserSession";
@ -11,6 +11,10 @@ import {UserSession} from "./UserSession";
* A collection of commands executed within a particular user session. * A collection of commands executed within a particular user session.
*/ */
export class Commands { export class Commands {
/**
* The environment in which commands are executed.
*/
private readonly environment: Environment;
/** /**
* The user session describing the user that executes commands. * 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. * 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 userSession the user session describing the user that executes commands
* @param fileSystem the file system to interact with * @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.userSession = userSession;
this.fileSystem = fileSystem; this.fileSystem = fileSystem;
this.commands = { 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(), If more than one directory is given, the directories are removed in the order they are given in.`.trimLines(),
new InputValidator({minArgs: 1}) 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( "touch": new Command(
this.touch, this.touch,
`change file timestamps`, `change file timestamps`,
@ -202,6 +216,7 @@ export class Commands {
Cookies.remove("files"); Cookies.remove("files");
Cookies.remove("cwd"); Cookies.remove("cwd");
Cookies.remove("user"); Cookies.remove("user");
Cookies.remove("env");
location.reload(); location.reload();
throw new Error("Goodbye"); throw new Error("Goodbye");
} }
@ -265,7 +280,7 @@ export class Commands {
private echo(input: InputArgs): string { private echo(input: InputArgs): string {
return input.args.join(" ").replace("hunter2", "*******") return input.args.join(" ").replace("hunter2", "*******")
+ (input.hasOption("n") ? "\n" : ""); + (input.hasOption("n") ? "" : "\n");
} }
private exit(): string { private exit(): string {
@ -399,6 +414,18 @@ export class Commands {
return this.fileSystem.rmdirs(input.args); 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 { private touch(input: InputArgs): string {
return this.fileSystem.createFiles(input.args); return this.fileSystem.createFiles(input.args);
} }

View File

@ -412,7 +412,7 @@ export class FileSystem {
* @return an empty string if the removal was successful, or a message explaining what went wrong * @return an empty string if the removal was successful, or a message explaining what went wrong
*/ */
private rmdir(pathString: string): string { private rmdir(pathString: string): string {
const path = this._cwd.getChild(pathString); const path = this.getPathTo(pathString);
if (path.toString() === "/") { if (path.toString() === "/") {
if (this.root.nodeCount > 0) if (this.root.nodeCount > 0)

View File

@ -10,12 +10,16 @@ import {UserSession} from "./UserSession";
* A shell that interacts with the user session and file system to execute commands. * A shell that interacts with the user session and file system to execute commands.
*/ */
export class Shell { export class Shell {
/**
* The environment in which commands are executed.
*/
private readonly environment: Environment;
/** /**
* The history of the user's inputs. * The history of the user's inputs.
*/ */
private readonly inputHistory: InputHistory; 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; private readonly userSession: UserSession;
/** /**
@ -76,7 +80,16 @@ export class Shell {
console.warn("Failed to set cwd from cookie."); 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 * @param inputString the input to process
*/ */
execute(inputString: string): string { execute(inputString: string): string {
if (!this.userSession.isLoggedIn) { if (!this.userSession.isLoggedIn) {
if (this.attemptUser === undefined) { if (this.attemptUser === undefined) {
this.attemptUser = inputString.trim(); this.attemptUser = inputString.trim();
@ -153,7 +166,7 @@ export class Shell {
this.inputHistory.addEntry(inputString.trim()); 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") { if (input.redirectTarget[0] === "write") {
const rms = this.fileSystem.rms([input.redirectTarget[1]], true); const rms = this.fileSystem.rms([input.redirectTarget[1]], true);
if (rms !== "") if (rms !== "")
@ -208,6 +221,7 @@ export class Shell {
"path": "/" "path": "/"
}); });
Cookies.set("cwd", this.fileSystem.cwd, {"path": "/"}); Cookies.set("cwd", this.fileSystem.cwd, {"path": "/"});
Cookies.set("env", this.environment, {"path": "/"});
const user = this.userSession.currentUser; const user = this.userSession.currentUser;
Cookies.set("user", user === undefined ? "" : user.name, {"path": "/"}); 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. * The options given to a command.
*/ */
@ -327,6 +346,22 @@ export class InputArgs {
* A parser for input strings. * A parser for input strings.
*/ */
export class InputParser { 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. * 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 tokens = this.tokenize(input);
const command = tokens[0] || ""; const command = tokens[0] || "";
const [options, args] = const [options, args] =
this.parseOpts( this.parseOpts(tokens.slice(1).filter(it => !it.startsWith(`${EscapeCharacters.Escape}`)));
tokens.slice(1)
.filter(it => !it.startsWith(">"))
.map(it => it.replace(/\\>/, ">"))
);
const redirectTarget = this.getRedirectTarget(tokens.slice(1)); const redirectTarget = this.getRedirectTarget(tokens.slice(1));
return new InputArgs(command, options, args, redirectTarget); 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 * @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] { private getNextToken(input: string): [string, string] {
let token = ""; let token = "";
@ -366,32 +415,10 @@ export class InputParser {
throw new Error("Unexpected end of input. `\\` was used but there was nothing to escape."); throw new Error("Unexpected end of input. `\\` was used but there was nothing to escape.");
const nextChar = input[i + 1]; const nextChar = input[i + 1];
switch (nextChar) { if (isInSingleQuotes || isInDoubleQuotes)
case "\\": token += "\\" + nextChar;
token += "\\"; else
break; token += nextChar;
case "/":
if (isInSingleQuotes || isInDoubleQuotes)
token += "\\/";
else
token += "/";
break;
case "'":
token += "'";
break;
case "\"":
token += "\"";
break;
case " ":
token += " ";
break;
case ">":
token += "\\>";
break;
default:
token += "\\" + nextChar;
break;
}
i++; i++;
break; break;
case "'": case "'":
@ -413,22 +440,28 @@ export class InputParser {
return [token, input.slice(i + 1)]; return [token, input.slice(i + 1)];
break; break;
case ">": case ">":
if (!isInSingleQuotes && !isInDoubleQuotes) { if (isInSingleQuotes || isInDoubleQuotes) {
if (token !== "") token += char;
return [token, input.slice(i)]; break;
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;
} }
// 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; break;
default: default:
token += char; 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 * @param input the string to find the first environment variable in
* @return the array of tokens found in the input string * @return the value of the first environment variable in the given string and the length of the variable name
*/ */
private tokenize(input: string): string[] { private getNextVariable(input: string): [string, number] {
const tokens = []; 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 !== "") { variable += char;
let token;
[token, input] = this.getNextToken(input);
tokens.push(token);
} }
return [this.environment[variable] || "", i];
return tokens;
} }
/** /**
@ -472,10 +506,10 @@ export class InputParser {
let redirectTarget: ["default"] | ["write" | "append", string] = ["default"]; let redirectTarget: ["default"] | ["write" | "append", string] = ["default"];
tokens.forEach(token => { tokens.forEach(token => {
if (token.startsWith(">>")) if (token.startsWith(`${EscapeCharacters.Escape}>>`))
redirectTarget = ["append", token.slice(2)]; redirectTarget = ["append", token.slice(3)];
else if (token.startsWith(">")) else if (token.startsWith(`${EscapeCharacters.Escape}>`))
redirectTarget = ["write", token.slice(1)]; redirectTarget = ["write", token.slice(2)];
}); });
return redirectTarget; return redirectTarget;

View File

@ -8,10 +8,12 @@ import {InputParser} from "../main/js/Shell";
describe("input args", () => { describe("input args", () => {
let parser: InputParser; let parser: InputParser;
beforeEach(() => { beforeEach(() => {
parser = new InputParser(); parser = new InputParser({});
}); });
describe("tokenization", () => { describe("tokenization", () => {
it("concatenates multiple strings into one token", () => { it("concatenates multiple strings into one token", () => {
expect(parser.parse(`'co'm"m nd"`).command).to.equal("comm nd"); 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", () => { it("includes escaped quotation marks into the token", () => {
expect(parser.parse(`com\\'man\\"d`).command).to.equal(`com'man"d`); 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", () => { describe("command", () => {
@ -206,4 +213,37 @@ describe("input args", () => {
expect(inputArgs.args).to.have.members([">", "file"]); 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"]);
});
});
}); });