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 {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);
}

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
*/
private rmdir(pathString: string): string {
const path = this._cwd.getChild(pathString);
const path = this.getPathTo(pathString);
if (path.toString() === "/") {
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.
*/
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;

View File

@ -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"]);
});
});
});